diff --git a/README.md b/README.md index 0796b46c..3acef8aa 100644 --- a/README.md +++ b/README.md @@ -122,4 +122,20 @@ vendor/bin/roave-backward-compatibility-check --help ## Configuration -There are currently no configuration options available. +The file `.roave-backward-compatibility-check.xml` is read from the current working directory (when it exists) and sets configuration for the command. + +It's expected to be an XML file that follows our [schema](resources/schema.xsd): + +**Example:** + +```xml + + + + #\[BC\] CHANGED: The parameter \$a of of TestArtifact\\TheClass\#method\(\)# + #\[BC\] CHANGED: The parameter \$b of of TestArtifact\\TheClass\#method2\(\)# + + +``` diff --git a/Resources/schema.xsd b/Resources/schema.xsd new file mode 100644 index 00000000..5a4cb174 --- /dev/null +++ b/Resources/schema.xsd @@ -0,0 +1,30 @@ + + + + + + + This schema file defines the structure for the XML configuration file of roave/backward-compatibility-check. + + + + + + + + + + + + + + + + + + + + + + + diff --git a/box.json.dist b/box.json.dist index 425ea7e1..7a89b72a 100644 --- a/box.json.dist +++ b/box.json.dist @@ -10,6 +10,7 @@ "main": "bin/roave-backward-compatibility-check.php", "output": "dist/roave-backward-compatibility-check.phar", "files-bin": [ + "Resources/schema.xsd", "LICENSE", "vendor/composer/composer/LICENSE" ], diff --git a/composer.json b/composer.json index 424038da..09a1a7c3 100644 --- a/composer.json +++ b/composer.json @@ -3,6 +3,10 @@ "description": "Tool to compare two revisions of a public API to check for BC breaks", "require": { "php": "~8.2.0 || ~8.3.0", + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-simplexml": "*", "azjezz/psl": "^3.0.2", "composer/composer": "^2.7.6", "nikic/php-parser": "^4.19.1", diff --git a/composer.lock b/composer.lock index e00e1efd..f7f22bbd 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4044c984df3464d5e8e83aafddf39fed", + "content-hash": "b45db2c9f3fcb8b5a29bab64852c0a36", "packages": [ { "name": "azjezz/psl", @@ -7350,7 +7350,11 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": "~8.2.0 || ~8.3.0" + "php": "~8.2.0 || ~8.3.0", + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-simplexml": "*" }, "platform-dev": {}, "platform-overrides": { diff --git a/src/Baseline.php b/src/Baseline.php new file mode 100644 index 00000000..1efa23ff --- /dev/null +++ b/src/Baseline.php @@ -0,0 +1,41 @@ + $ignoredChanges */ + private function __construct(private readonly array $ignoredChanges = []) + { + } + + public static function empty(): self + { + return new self(); + } + + /** @psalm-param list $ignoredChanges */ + public static function fromList(string ...$ignoredChanges): self + { + return new self(array_values($ignoredChanges)); + } + + public function ignores(Change $change): bool + { + $changeDescription = $change->__toString(); + + foreach ($this->ignoredChanges as $ignoredChangeRegex) { + if (preg_match($ignoredChangeRegex, $changeDescription) === 1) { + return true; + } + } + + return false; + } +} diff --git a/src/Changes.php b/src/Changes.php index 199652d7..d4770975 100644 --- a/src/Changes.php +++ b/src/Changes.php @@ -67,6 +67,23 @@ public function mergeWith(self $other): self return $instance; } + public function applyBaseline(Baseline $baseline): self + { + $instance = new self([]); + + $instance->unBufferedChanges = (function () use ($baseline): Generator { + foreach ($this as $change) { + if ($baseline->ignores($change)) { + continue; + } + + yield $change; + } + })(); + + return $instance; + } + /** * {@inheritDoc} * diff --git a/src/Command/AssertBackwardsCompatible.php b/src/Command/AssertBackwardsCompatible.php index 75dca68a..590d5eea 100644 --- a/src/Command/AssertBackwardsCompatible.php +++ b/src/Command/AssertBackwardsCompatible.php @@ -114,7 +114,9 @@ public function execute(InputInterface $input, OutputInterface $output): int $stdErr = $output->getErrorOutput(); // @todo fix flaky assumption about the path of the source repo... - $sourceRepo = CheckedOutRepository::fromPath(Env\current_dir()); + $currentDirectory = Env\current_dir(); + + $sourceRepo = CheckedOutRepository::fromPath($currentDirectory); $fromRevision = $input->getOption('from') !== null ? $this->parseRevisionFromInput($input, $sourceRepo) @@ -126,6 +128,8 @@ public function execute(InputInterface $input, OutputInterface $output): int $toRevision = $this->parseRevision->fromStringForRepository($to, $sourceRepo); + $configuration = (new DetermineConfigurationFromFilesystem())($currentDirectory, $stdErr); + $stdErr->writeln(Str\format( 'Comparing from %s to %s...', Type\string()->coerce($fromRevision), @@ -149,7 +153,7 @@ public function execute(InputInterface $input, OutputInterface $output): int $toPath->__toString(), ($this->locateDependencies)($toPath->__toString(), $includeDevelopmentDependencies), ), - ); + )->applyBaseline($configuration->baseline); $formatters = [ 'console' => new SymfonyConsoleTextFormatter($stdErr), diff --git a/src/Command/DetermineConfigurationFromFilesystem.php b/src/Command/DetermineConfigurationFromFilesystem.php new file mode 100644 index 00000000..2be952f4 --- /dev/null +++ b/src/Command/DetermineConfigurationFromFilesystem.php @@ -0,0 +1,37 @@ +parser->parse($currentDirectory); + + if ($configuration->filename !== null) { + $stdErr->writeln(Str\format( + 'Using "%s" as configuration file', + Type\string()->coerce($configuration->filename), + )); + } + + return $configuration; + } +} diff --git a/src/Configuration/Configuration.php b/src/Configuration/Configuration.php new file mode 100644 index 00000000..e5a7bbc5 --- /dev/null +++ b/src/Configuration/Configuration.php @@ -0,0 +1,27 @@ + $errors */ + public static function fromLibxmlErrors(array $errors): self + { + $message = 'The provided configuration is invalid, errors:' . PHP_EOL; + + foreach ($errors as $error) { + $message .= sprintf( + ' - [Line %d] %s' . PHP_EOL, + $error->line, + trim($error->message), + ); + } + + return new self($message); + } +} diff --git a/src/Configuration/ParseConfigurationFile.php b/src/Configuration/ParseConfigurationFile.php new file mode 100644 index 00000000..ffc3723d --- /dev/null +++ b/src/Configuration/ParseConfigurationFile.php @@ -0,0 +1,11 @@ +validateStructure($xmlContents); + } catch (File\Exception\InvalidArgumentException) { + return Configuration::default(); + } + + $configuration = new SimpleXMLElement($xmlContents); + + return Configuration::fromFile( + $this->parseBaseline($configuration), + $filename, + ); + } + + private function validateStructure(string $xmlContents): void + { + $previousConfiguration = libxml_use_internal_errors(true); + + $xmlDocument = new DOMDocument(); + $xmlDocument->loadXML($xmlContents); + + $configurationIsValid = $xmlDocument->schemaValidate(self::SCHEMA); + + $parsingErrors = libxml_get_errors(); + libxml_use_internal_errors($previousConfiguration); + + if ($configurationIsValid) { + return; + } + + throw InvalidConfigurationStructure::fromLibxmlErrors($parsingErrors); + } + + private function parseBaseline(SimpleXMLElement $element): Baseline + { + $ignoredItems = []; + + foreach ($element->xpath('baseline/ignored-regex') ?? [] as $node) { + $ignoredItem = (string) $node; + + assert($ignoredItem !== ''); + $ignoredItems[] = $ignoredItem; + } + + return Baseline::fromList(...$ignoredItems); + } +} diff --git a/test/e2e/Command/AssertBackwardsCompatibleTest.php b/test/e2e/Command/AssertBackwardsCompatibleTest.php index 5bd62902..becc433e 100644 --- a/test/e2e/Command/AssertBackwardsCompatibleTest.php +++ b/test/e2e/Command/AssertBackwardsCompatibleTest.php @@ -30,6 +30,15 @@ final class AssertBackwardsCompatibleTest extends TestCase JSON; + private const BASELINE_CONFIGURATION = <<<'XML' + + + + #\[BC\] CHANGED: The parameter \$a of TestArtifact\\TheClass\#method.*# + + +XML; + private const CLASS_VERSIONS = [ <<<'PHP' sourcesRepository . '/.roave-backward-compatibility-check.xml', self::BASELINE_CONFIGURATION); + + $output = Shell\execute(__DIR__ . '/../../../bin/roave-backward-compatibility-check', [ + '--from=' . $this->versions[0], + '--to=' . $this->versions[1], + ], $this->sourcesRepository, [], Shell\ErrorOutputBehavior::Append); + + self::assertStringContainsString( + '.roave-backward-compatibility-check.xml" as configuration file', + $output, + ); + } + private function tagOnVersion(string $tagName, int $version): void { Shell\execute('git', ['checkout', $this->versions[$version]], $this->sourcesRepository); diff --git a/test/unit/ChangesTest.php b/test/unit/ChangesTest.php index 8e2b5283..67662c58 100644 --- a/test/unit/ChangesTest.php +++ b/test/unit/ChangesTest.php @@ -7,6 +7,7 @@ use Generator; use PHPUnit\Framework\TestCase; use Psl\Type; +use Roave\BackwardCompatibility\Baseline; use Roave\BackwardCompatibility\Change; use Roave\BackwardCompatibility\Changes; @@ -118,4 +119,41 @@ public function testCount(): void Changes::fromList(...array_fill(0, $count, Change::added('foo', true))), ); } + + /** @psalm-suppress UnusedVariable by-ref assignments are in place to override the behavior of the spy/callback */ + public function testApplyBaselineWillApplyTheFilterWithoutLoadingChanges(): void + { + $stopProducingValues = static function (): void { + self::fail('No values should have been produced'); + }; + + $changesProvider = static function () use (&$stopProducingValues): Generator { + /** @psalm-var callable(): void $stopProducingValues */ + $stopProducingValues(); + + yield Change::changed('a', true); + + $stopProducingValues(); + + yield Change::changed('b', false); + }; + + $changes = Changes::fromIterator($changesProvider()); + $filtered = $changes->applyBaseline(Baseline::fromList('#a#')); + + self::assertNotSame($changes, $filtered, 'New instance should be created'); + + $stopProducingValues = static function (): void { + }; + + $expectedChanges = [Change::changed('b', false)]; + + self::assertEquals($expectedChanges, iterator_to_array($filtered)); + self::assertEquals( + $expectedChanges, + iterator_to_array($filtered), + 'Changes can be iterated upon more than once (they are buffered)', + ); + self::assertCount(1, $filtered); + } } diff --git a/test/unit/Configuration/ConfigurationTest.php b/test/unit/Configuration/ConfigurationTest.php new file mode 100644 index 00000000..4510fb44 --- /dev/null +++ b/test/unit/Configuration/ConfigurationTest.php @@ -0,0 +1,21 @@ +baseline); + self::assertNull($config->filename); + } +} diff --git a/test/unit/Configuration/ParseXmlConfigurationFileTest.php b/test/unit/Configuration/ParseXmlConfigurationFileTest.php new file mode 100644 index 00000000..661fb51a --- /dev/null +++ b/test/unit/Configuration/ParseXmlConfigurationFileTest.php @@ -0,0 +1,193 @@ +temporaryDirectory = Filesystem\create_temporary_file( + Env\temp_dir(), + 'roave-backward-compatibility-xml-config-test', + ); + + self::assertNotEmpty($this->temporaryDirectory); + self::assertFileExists($this->temporaryDirectory); + + Filesystem\delete_file($this->temporaryDirectory); + Filesystem\create_directory($this->temporaryDirectory); + } + + /** @after */ + public function cleanUpFilesystem(): void + { + Shell\execute('rm', ['-rf', $this->temporaryDirectory]); + } + + /** @test */ + public function defaultConfigurationShouldBeUsedWhenFileDoesNotExist(): void + { + $config = (new ParseXmlConfigurationFile())->parse($this->temporaryDirectory); + + self::assertEquals(Configuration::default(), $config); + } + + /** + * @test + * @dataProvider invalidConfiguration + */ + public function exceptionShouldBeRaisedWhenStructureIsInvalid( + string $xmlContents, + string $expectedError, + ): void { + File\write($this->temporaryDirectory . '/.roave-backward-compatibility-check.xml', $xmlContents); + + $this->expectException(InvalidConfigurationStructure::class); + $this->expectExceptionMessage($expectedError); + + (new ParseXmlConfigurationFile())->parse($this->temporaryDirectory); + } + + /** @return iterable */ + public static function invalidConfiguration(): iterable + { + yield 'invalid root element' => [ + <<<'XML' + + +XML, + '[Line 2] Element \'anything\': No matching global declaration available for the validation root', + ]; + + yield 'invalid root child' => [ + <<<'XML' + + + + +XML, + '[Line 3] Element \'something\': This element is not expected. Expected is ( baseline )', + ]; + + yield 'multiple baseline tags' => [ + <<<'XML' + + + + + +XML, + '[Line 4] Element \'baseline\': This element is not expected', + ]; + + yield 'invalid baseline child' => [ + <<<'XML' + + + + + + +XML, + '[Line 4] Element \'nothing\': This element is not expected. Expected is ( ignored-regex )', + ]; + + yield 'invalid ignored item type' => [ + <<<'XML' + + + + + + + + +XML, + '[Line 4] Element \'ignored-regex\': Element content is not allowed, because the type definition is simple', + ]; + } + + /** + * @test + * @dataProvider validConfiguration + */ + public function baselineShouldBeParsed( + string $xmlContents, + Baseline $expectedBaseline, + ): void { + File\write($this->temporaryDirectory . '/.roave-backward-compatibility-check.xml', $xmlContents); + + self::assertEquals( + Configuration::fromFile( + $expectedBaseline, + $this->temporaryDirectory . '/.roave-backward-compatibility-check.xml', + ), + (new ParseXmlConfigurationFile())->parse($this->temporaryDirectory), + ); + } + + /** @return iterable */ + public static function validConfiguration(): iterable + { + yield 'no baseline' => [ + <<<'XML' + + +XML, + Baseline::empty(), + ]; + + yield 'empty baseline' => [ + <<<'XML' + + + + +XML, + Baseline::empty(), + ]; + + yield 'baseline with single element' => [ + <<<'XML' + + + + #\[BC\] CHANGED: The parameter \$a of TestArtifact\\TheClass\#method.*# + + +XML, + Baseline::fromList('#\[BC\] CHANGED: The parameter \$a of TestArtifact\\\\TheClass\#method.*#'), + ]; + + yield 'baseline with multiple elements' => [ + <<<'XML' + + + + #\[BC\] CHANGED: The parameter \$a of TestArtifact\\TheClass\#method.*# + #\[BC\] ADDED: Method .*\(\) was added to interface TestArtifact\\TheInterface.*# + + +XML, + Baseline::fromList( + '#\[BC\] CHANGED: The parameter \$a of TestArtifact\\\\TheClass\#method.*#', + '#\[BC\] ADDED: Method .*\(\) was added to interface TestArtifact\\\\TheInterface.*#', + ), + ]; + } +}