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.*#',
+ ),
+ ];
+ }
+}