Repository: Behat/Gherkin
Pull request #297: PHPStan level 9 fixes (tests only)
Author: login: uuf6429
Created: 2025-04-12T15:25:39Z | Merged: 2025-04-29T10:32:42Z

Since the jump to PHPStan level 9 introduces a lot of changes, I decided to split that in different tasks, with the final task actually changing the PHPStan level in the config file.

This PR in particular only changes things related to tests, so that reviewing should be relatively easy and relaxed.

Note: There's only one non-test file that was changed, but it was just to add some alias types.


Diff:
diff --git a/composer.json b/composer.json
index 216cabfe..24ae32ee 100644
--- a/composer.json
+++ b/composer.json
@@ -19,7 +19,6 @@
     },
 
     "require-dev": {
-        "symfony/filesystem": "^5.4 || ^6.4 || ^7.0",
         "symfony/yaml": "^5.4 || ^6.4 || ^7.0",
         "phpunit/phpunit": "^10.5",
         "cucumber/cucumber": "dev-gherkin-24.1.0",
diff --git a/src/Keywords/KeywordsInterface.php b/src/Keywords/KeywordsInterface.php
index 7e6d8b7c..046be343 100644
--- a/src/Keywords/KeywordsInterface.php
+++ b/src/Keywords/KeywordsInterface.php
@@ -14,6 +14,10 @@
  * Keywords holder interface.
  *
  * @author Konstantin Kudryashov <ever.zet@gmail.com>
+ *
+ * @phpstan-type TLanguage string
+ * @phpstan-type TKeywordsArray array{feature: string, background: string, scenario: string, scenario_outline: string, examples: string, given: string, when: string, then: string, and: string, but: string}
+ * @phpstan-type TMultiLanguageKeywords array<TLanguage, TKeywordsArray>
  */
 interface KeywordsInterface
 {
diff --git a/tests/Cache/FileCacheTest.php b/tests/Cache/FileCacheTest.php
index f5964854..ea1ba8bd 100644
--- a/tests/Cache/FileCacheTest.php
+++ b/tests/Cache/FileCacheTest.php
@@ -15,8 +15,11 @@
 use Behat\Gherkin\Node\FeatureNode;
 use Behat\Gherkin\Node\ScenarioNode;
 use PHPUnit\Framework\TestCase;
-use Symfony\Component\Filesystem\Filesystem;
+use Tests\Behat\Gherkin\Filesystem;
 
+/**
+ * @todo Use VFS
+ */
 class FileCacheTest extends TestCase
 {
     private string $path;
@@ -27,11 +30,6 @@ protected function setUp(): void
         $this->cache = new FileCache($this->path = sys_get_temp_dir() . uniqid('/gherkin-test'));
     }
 
-    protected function tearDown(): void
-    {
-        (new Filesystem())->remove($this->path);
-    }
-
     public function testIsFreshWhenThereIsNoFile(): void
     {
         $this->assertFalse($this->cache->isFresh('unexisting', time() + 100));
@@ -73,7 +71,8 @@ public function testBrokenCacheRead(): void
             'broken_feature',
             new FeatureNode(null, null, [], null, [], '', '', null, 1),
         );
-        $files = glob($this->path . '/**/*.feature.cache');
+        $files = Filesystem::find("$this->path/**/*.feature.cache");
+
         $this->assertCount(1, $files, 'Cache should have written a single file');
 
         // Now simulate the file being corrupted and attempt to read it
diff --git a/tests/Cucumber/CompatibilityTest.php b/tests/Cucumber/CompatibilityTest.php
index 63d6dcba..b6d4ca5a 100644
--- a/tests/Cucumber/CompatibilityTest.php
+++ b/tests/Cucumber/CompatibilityTest.php
@@ -15,7 +15,6 @@
 use Behat\Gherkin\Lexer;
 use Behat\Gherkin\Loader\CucumberNDJsonAstLoader;
 use Behat\Gherkin\Node\FeatureNode;
-use Behat\Gherkin\Node\StepNode;
 use Behat\Gherkin\Parser;
 use FilesystemIterator;
 use PHPUnit\Framework\Attributes\DataProvider;
@@ -23,6 +22,7 @@
 use ReflectionClass;
 use RuntimeException;
 use SplFileInfo;
+use Tests\Behat\Gherkin\Filesystem;
 
 /**
  * Tests the parser against the upstream cucumber/gherkin test data.
@@ -33,6 +33,9 @@ class CompatibilityTest extends TestCase
 {
     public const TESTDATA_PATH = __DIR__ . '/../../vendor/cucumber/cucumber/gherkin/testdata';
 
+    /**
+     * @var array<string, string>
+     */
     private array $notParsingCorrectly = [
         'complex_background.feature' => 'Rule keyword not supported',
         'rule.feature' => 'Rule keyword not supported',
@@ -50,10 +53,16 @@ class CompatibilityTest extends TestCase
         'incomplete_background_2.feature' => 'Wrong background parsing when there are no steps',
     ];
 
+    /**
+     * @var array<string, string>
+     */
     private array $parsedButShouldNotBe = [
         'invalid_language.feature' => 'Invalid language is silently ignored',
     ];
 
+    /**
+     * @var array<string, string>
+     */
     private array $deprecatedInsteadOfParseError = [
         'whitespace_in_tags.feature' => '/Whitespace in tags is deprecated/',
     ];
@@ -78,15 +87,15 @@ public function testFeaturesParseTheSameAsCucumber(SplFileInfo $file): void
         }
 
         $gherkinFile = $file->getPathname();
-
-        $actual = $this->parser->parse(file_get_contents($gherkinFile), $gherkinFile);
+        $actual = $this->parser->parse(Filesystem::readFile($gherkinFile), $gherkinFile);
         $cucumberFeatures = $this->loader->load($gherkinFile . '.ast.ndjson');
+
         $expected = $cucumberFeatures ? $cucumberFeatures[0] : null;
 
         $this->assertEquals(
             $this->normaliseFeature($expected),
             $this->normaliseFeature($actual),
-            file_get_contents($gherkinFile)
+            Filesystem::readFile($gherkinFile)
         );
     }
 
@@ -97,13 +106,15 @@ public function testBadFeaturesDoNotParse(SplFileInfo $file): void
             $this->markTestIncomplete($this->parsedButShouldNotBe[$file->getFilename()]);
         }
 
+        $gherkinFile = $file->getPathname();
+
         if (isset($this->deprecatedInsteadOfParseError[$file->getFilename()])) {
             $this->expectDeprecationErrorMatches($this->deprecatedInsteadOfParseError[$file->getFilename()]);
         } else {
             $this->expectException(ParserException::class);
         }
-        $gherkinFile = $file->getPathname();
-        $this->parser->parse(file_get_contents($gherkinFile), $gherkinFile);
+
+        $this->parser->parse(Filesystem::readFile($gherkinFile), $gherkinFile);
     }
 
     /**
@@ -127,7 +138,11 @@ public static function badCucumberFeatures(): iterable
      */
     private static function getCucumberFeatures(string $folder): iterable
     {
-        foreach (new FilesystemIterator(self::TESTDATA_PATH . $folder) as $file) {
+        $fileIterator = new FilesystemIterator(self::TESTDATA_PATH . $folder);
+        /**
+         * @var iterable<string, SplFileInfo> $fileIterator
+         */
+        foreach ($fileIterator as $file) {
             if ($file->isFile() && $file->getExtension() === 'feature') {
                 yield $file->getFilename() => ['file' => $file];
             }
@@ -144,17 +159,10 @@ private function normaliseFeature(?FeatureNode $featureNode): ?FeatureNode
         }
 
         foreach ($featureNode->getScenarios() as $scenarioNode) {
-            $steps = array_map(
-                function (StepNode $step) {
-                    $this->setPrivateProperty($step, 'keywordType', '');
-                    $this->setPrivateProperty($step, 'arguments', []);
-
-                    return $step;
-                },
-                $scenarioNode->getSteps()
-            );
-
-            $this->setPrivateProperty($scenarioNode, 'steps', $steps);
+            foreach ($scenarioNode->getSteps() as $step) {
+                $this->setPrivateProperty($step, 'keywordType', '');
+                $this->setPrivateProperty($step, 'arguments', []);
+            }
         }
 
         return $featureNode;
diff --git a/tests/Filesystem.php b/tests/Filesystem.php
new file mode 100644
index 00000000..e8f1307a
--- /dev/null
+++ b/tests/Filesystem.php
@@ -0,0 +1,32 @@
+<?php
+
+/*
+ * This file is part of the Behat Gherkin Parser.
+ * (c) Konstantin Kudryashov <ever.zet@gmail.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Tests\Behat\Gherkin;
+
+class Filesystem
+{
+    public static function readFile(string $fileName): string
+    {
+        $data = @file_get_contents($fileName);
+        if ($data === false) {
+            throw new \RuntimeException("Failed to read file: $fileName");
+        }
+
+        return $data;
+    }
+
+    /**
+     * @return list<string>
+     */
+    public static function find(string $pattern): array
+    {
+        return glob($pattern) ?: [];
+    }
+}
diff --git a/tests/Filter/FilterTestCase.php b/tests/Filter/FilterTestCase.php
index d8cece78..d6d5b5f3 100644
--- a/tests/Filter/FilterTestCase.php
+++ b/tests/Filter/FilterTestCase.php
@@ -12,12 +12,13 @@
 
 use Behat\Gherkin\Keywords\ArrayKeywords;
 use Behat\Gherkin\Lexer;
+use Behat\Gherkin\Node\FeatureNode;
 use Behat\Gherkin\Parser;
 use PHPUnit\Framework\TestCase;
 
 abstract class FilterTestCase extends TestCase
 {
-    protected function getParser()
+    protected function getParser(): Parser
     {
         return new Parser(
             new Lexer(
@@ -39,7 +40,7 @@ protected function getParser()
         );
     }
 
-    protected function getGherkinFeature()
+    protected function getGherkinFeature(): string
     {
         return <<<'GHERKIN'
         Feature: Long feature with outline
@@ -72,8 +73,9 @@ protected function getGherkinFeature()
         GHERKIN;
     }
 
-    protected function getParsedFeature()
+    protected function getParsedFeature(): FeatureNode
     {
-        return $this->getParser()->parse($this->getGherkinFeature());
+        return $this->getParser()->parse($this->getGherkinFeature())
+            ?? throw new \LogicException('Could not parse predefined feature in getGherkinFeature()');
     }
 }
diff --git a/tests/Filter/LineRangeFilterTest.php b/tests/Filter/LineRangeFilterTest.php
index 8051e174..64afb044 100644
--- a/tests/Filter/LineRangeFilterTest.php
+++ b/tests/Filter/LineRangeFilterTest.php
@@ -19,6 +19,9 @@
 
 class LineRangeFilterTest extends FilterTestCase
 {
+    /**
+     * @return iterable<array{numeric-string, numeric-string|'*', bool}>
+     */
     public static function featureLineRangeProvider(): iterable
     {
         return [
@@ -30,6 +33,10 @@ public static function featureLineRangeProvider(): iterable
         ];
     }
 
+    /**
+     * @param numeric-string $filterMinLine
+     * @param numeric-string|'*' $filterMaxLine
+     */
     #[DataProvider('featureLineRangeProvider')]
     public function testIsFeatureMatchFilter(string $filterMinLine, string $filterMaxLine, bool $expected): void
     {
@@ -39,6 +46,9 @@ public function testIsFeatureMatchFilter(string $filterMinLine, string $filterMa
         $this->assertSame($expected, $filter->isFeatureMatch($feature));
     }
 
+    /**
+     * @return iterable<array{numeric-string, numeric-string|'*', int}>
+     */
     public static function scenarioLineRangeProvider(): iterable
     {
         return [
@@ -54,6 +64,10 @@ public static function scenarioLineRangeProvider(): iterable
         ];
     }
 
+    /**
+     * @param numeric-string $filterMinLine
+     * @param numeric-string|'*' $filterMaxLine
+     */
     #[DataProvider('scenarioLineRangeProvider')]
     public function testIsScenarioMatchFilter(string $filterMinLine, string $filterMaxLine, int $expectedNumberOfMatches): void
     {
diff --git a/tests/GherkinTest.php b/tests/GherkinTest.php
index b0b57592..c4b47668 100644
--- a/tests/GherkinTest.php
+++ b/tests/GherkinTest.php
@@ -195,6 +195,9 @@ public function testResourceLineFilter(string $resource, array $features, string
         $this->assertEquals($expectedFeatures, $gherkin->load($resource));
     }
 
+    /**
+     * @return iterable<string, array{resource: string, features: list<FeatureNode>, expectedResource: string, expectedFeatures: list<FeatureNode>}>
+     */
     public static function resourceLineFilterDataProvider(): iterable
     {
         // For this test, let's assume that each feature takes up 3 lines
diff --git a/tests/Keywords/ArrayKeywordsTest.php b/tests/Keywords/ArrayKeywordsTest.php
index a87c43ca..d5bacbbe 100644
--- a/tests/Keywords/ArrayKeywordsTest.php
+++ b/tests/Keywords/ArrayKeywordsTest.php
@@ -11,16 +11,17 @@
 namespace Tests\Behat\Gherkin\Keywords;
 
 use Behat\Gherkin\Keywords\ArrayKeywords;
+use Behat\Gherkin\Keywords\KeywordsInterface;
 use Behat\Gherkin\Node\StepNode;
 
 class ArrayKeywordsTest extends KeywordsTestCase
 {
-    protected function getKeywords()
+    protected static function getKeywords(): KeywordsInterface
     {
-        return new ArrayKeywords($this->getKeywordsArray());
+        return new ArrayKeywords(static::getKeywordsArray());
     }
 
-    protected function getKeywordsArray()
+    protected static function getKeywordsArray(): array
     {
         return [
             'with_special_chars' => [
@@ -40,7 +41,7 @@ protected function getKeywordsArray()
         ];
     }
 
-    protected function getSteps($keywords, $text, &$line, $keywordType)
+    protected static function getSteps(string $keywords, string $text, int &$line, ?string $keywordType): array
     {
         $steps = [];
         foreach (explode('|', $keywords) as $keyword) {
diff --git a/tests/Keywords/CachedArrayKeywordsTest.php b/tests/Keywords/CachedArrayKeywordsTest.php
index 4b37e6e4..e49b9e32 100644
--- a/tests/Keywords/CachedArrayKeywordsTest.php
+++ b/tests/Keywords/CachedArrayKeywordsTest.php
@@ -11,22 +11,23 @@
 namespace Tests\Behat\Gherkin\Keywords;
 
 use Behat\Gherkin\Keywords\CachedArrayKeywords;
+use Behat\Gherkin\Keywords\KeywordsInterface;
 use Behat\Gherkin\Node\StepNode;
 
 class CachedArrayKeywordsTest extends KeywordsTestCase
 {
-    protected function getKeywords()
+    protected static function getKeywords(): KeywordsInterface
     {
         // Test with the default i18n file provided in this repository
         return CachedArrayKeywords::withDefaultKeywords();
     }
 
-    protected function getKeywordsArray()
+    protected static function getKeywordsArray(): array
     {
         return require __DIR__ . '/../../i18n.php';
     }
 
-    protected function getSteps($keywords, $text, &$line, $keywordType)
+    protected static function getSteps(string $keywords, string $text, int &$line, ?string $keywordType): array
     {
         $steps = [];
         foreach (explode('|', $keywords) as $keyword) {
diff --git a/tests/Keywords/CucumberKeywordsTest.php b/tests/Keywords/CucumberKeywordsTest.php
index 8fa993a0..523ebf0a 100644
--- a/tests/Keywords/CucumberKeywordsTest.php
+++ b/tests/Keywords/CucumberKeywordsTest.php
@@ -11,23 +11,24 @@
 namespace Tests\Behat\Gherkin\Keywords;
 
 use Behat\Gherkin\Keywords\CucumberKeywords;
+use Behat\Gherkin\Keywords\KeywordsInterface;
 use Behat\Gherkin\Node\StepNode;
 use Symfony\Component\Yaml\Exception\ParseException;
 use Symfony\Component\Yaml\Yaml;
 
 class CucumberKeywordsTest extends KeywordsTestCase
 {
-    protected function getKeywords()
+    protected static function getKeywords(): KeywordsInterface
     {
         return new CucumberKeywords(__DIR__ . '/../Fixtures/i18n.yml');
     }
 
-    protected function getKeywordsArray()
+    protected static function getKeywordsArray(): array
     {
-        return Yaml::parse(file_get_contents(__DIR__ . '/../Fixtures/i18n.yml'));
+        return Yaml::parseFile(__DIR__ . '/../Fixtures/i18n.yml');
     }
 
-    protected function getSteps($keywords, $text, &$line, $keywordType)
+    protected static function getSteps(string $keywords, string $text, int &$line, ?string $keywordType): array
     {
         $steps = [];
         foreach (explode('|', mb_substr($keywords, 2)) as $keyword) {
@@ -69,6 +70,9 @@ public function testYamlRootMustBeAnArray(): void
         new CucumberKeywords("a\nstring");
     }
 
+    /**
+     * @todo Use VFS
+     */
     public function testYamlFileMustBeReadable(): void
     {
         $tempFile = tempnam(sys_get_temp_dir(), 'test');
diff --git a/tests/Keywords/KeywordsDumperTest.php b/tests/Keywords/KeywordsDumperTest.php
index b354ca33..10706199 100644
--- a/tests/Keywords/KeywordsDumperTest.php
+++ b/tests/Keywords/KeywordsDumperTest.php
@@ -16,7 +16,7 @@
 
 class KeywordsDumperTest extends TestCase
 {
-    private $keywords;
+    private ArrayKeywords $keywords;
 
     protected function setUp(): void
     {
@@ -48,7 +48,7 @@ protected function setUp(): void
         ]);
     }
 
-    public function testEnKeywordsDumper()
+    public function testEnKeywordsDumper(): void
     {
         $dumper = new KeywordsDumper($this->keywords);
 
@@ -85,7 +85,7 @@ public function testEnKeywordsDumper()
         $this->assertEquals($etalon, $dumped);
     }
 
-    public function testRuKeywordsDumper()
+    public function testRuKeywordsDumper(): void
     {
         $dumper = new KeywordsDumper($this->keywords);
 
@@ -123,7 +123,7 @@ public function testRuKeywordsDumper()
         $this->assertEquals($etalon, $dumped);
     }
 
-    public function testRuKeywordsCustomKeywordsDumper()
+    public function testRuKeywordsCustomKeywordsDumper(): void
     {
         $dumper = new KeywordsDumper($this->keywords);
         $dumper->setKeywordsDumperFunction(function ($keywords) {
@@ -164,7 +164,7 @@ public function testRuKeywordsCustomKeywordsDumper()
         $this->assertEquals($etalon, $dumped);
     }
 
-    public function testExtendedVersionDumper()
+    public function testExtendedVersionDumper(): void
     {
         $dumper = new KeywordsDumper($this->keywords);
 
diff --git a/tests/Keywords/KeywordsTestCase.php b/tests/Keywords/KeywordsTestCase.php
index aa5062e4..58495b1a 100644
--- a/tests/Keywords/KeywordsTestCase.php
+++ b/tests/Keywords/KeywordsTestCase.php
@@ -11,29 +11,43 @@
 namespace Tests\Behat\Gherkin\Keywords;
 
 use Behat\Gherkin\Keywords\KeywordsDumper;
+use Behat\Gherkin\Keywords\KeywordsInterface;
 use Behat\Gherkin\Lexer;
 use Behat\Gherkin\Node\BackgroundNode;
 use Behat\Gherkin\Node\ExampleTableNode;
 use Behat\Gherkin\Node\FeatureNode;
 use Behat\Gherkin\Node\OutlineNode;
 use Behat\Gherkin\Node\ScenarioNode;
+use Behat\Gherkin\Node\StepNode;
 use Behat\Gherkin\Parser;
-use Exception;
+use PHPUnit\Framework\Attributes\DataProvider;
 use PHPUnit\Framework\TestCase;
 
+/**
+ * @phpstan-import-type TMultiLanguageKeywords from KeywordsInterface
+ */
 abstract class KeywordsTestCase extends TestCase
 {
-    abstract protected function getKeywords();
+    abstract protected static function getKeywords(): KeywordsInterface;
 
-    abstract protected function getKeywordsArray();
+    /**
+     * @phpstan-return TMultiLanguageKeywords
+     */
+    abstract protected static function getKeywordsArray(): array;
 
-    abstract protected function getSteps($keywords, $text, &$line, $keywordType);
+    /**
+     * @return list<StepNode>
+     */
+    abstract protected static function getSteps(string $keywords, string $text, int &$line, ?string $keywordType): array;
 
-    public function translationTestDataProvider(): array
+    /**
+     * @return iterable<string, array{language: string, num: int, etalon: FeatureNode, source: string}>
+     */
+    public static function translationTestDataProvider(): iterable
     {
-        $keywords = $this->getKeywords();
+        $keywords = static::getKeywords();
         $dumper = new KeywordsDumper($keywords);
-        $keywordsArray = $this->getKeywordsArray();
+        $keywordsArray = static::getKeywordsArray();
 
         // Remove languages with repeated keywords
         unset($keywordsArray['en-old'], $keywordsArray['uz'], $keywordsArray['ne']);
@@ -54,8 +68,8 @@ public function translationTestDataProvider(): array
                 $backgroundLine = $line;
                 ++$line;
                 $background = new BackgroundNode(null, array_merge(
-                    $this->getSteps($i18nKeywords['given'], 'there is agent A', $line, 'Given'),
-                    $this->getSteps($i18nKeywords['and'], 'there is agent B', $line, 'Given')
+                    static::getSteps($i18nKeywords['given'], 'there is agent A', $line, 'Given'),
+                    static::getSteps($i18nKeywords['and'], 'there is agent B', $line, 'Given')
                 ), $keywords[0], $backgroundLine);
 
                 ++$line;
@@ -67,11 +81,11 @@ public function translationTestDataProvider(): array
                     ++$line;
 
                     $steps = array_merge(
-                        $this->getSteps($i18nKeywords['given'], 'there is agent J', $line, 'Given'),
-                        $this->getSteps($i18nKeywords['and'], 'there is agent K', $line, 'Given'),
-                        $this->getSteps($i18nKeywords['when'], 'I erase agent K\'s memory', $line, 'When'),
-                        $this->getSteps($i18nKeywords['then'], 'there should be agent J', $line, 'Then'),
-                        $this->getSteps($i18nKeywords['but'], 'there should not be agent K', $line, 'Then')
+                        static::getSteps($i18nKeywords['given'], 'there is agent J', $line, 'Given'),
+                        static::getSteps($i18nKeywords['and'], 'there is agent K', $line, 'Given'),
+                        static::getSteps($i18nKeywords['when'], 'I erase agent K\'s memory', $line, 'When'),
+                        static::getSteps($i18nKeywords['then'], 'there should be agent J', $line, 'Then'),
+                        static::getSteps($i18nKeywords['but'], 'there should not be agent K', $line, 'Then')
                     );
 
                     $scenarios[] = new ScenarioNode('Erasing agent memory', [], $steps, $scenarioKeyword, $scenarioLine);
@@ -82,11 +96,11 @@ public function translationTestDataProvider(): array
                     ++$line;
 
                     $steps = array_merge(
-                        $this->getSteps($i18nKeywords['given'], 'there is agent <agent1>', $line, 'Given'),
-                        $this->getSteps($i18nKeywords['and'], 'there is agent <agent2>', $line, 'Given'),
-                        $this->getSteps($i18nKeywords['when'], 'I erase agent <agent2>\'s memory', $line, 'When'),
-                        $this->getSteps($i18nKeywords['then'], 'there should be agent <agent1>', $line, 'Then'),
-                        $this->getSteps($i18nKeywords['but'], 'there should not be agent <agent2>', $line, 'Then')
+                        static::getSteps($i18nKeywords['given'], 'there is agent <agent1>', $line, 'Given'),
+                        static::getSteps($i18nKeywords['and'], 'there is agent <agent2>', $line, 'Given'),
+                        static::getSteps($i18nKeywords['when'], 'I erase agent <agent2>\'s memory', $line, 'When'),
+                        static::getSteps($i18nKeywords['then'], 'there should be agent <agent1>', $line, 'Then'),
+                        static::getSteps($i18nKeywords['but'], 'there should not be agent <agent2>', $line, 'Then')
                     );
                     ++$line;
 
@@ -107,8 +121,7 @@ public function translationTestDataProvider(): array
                     In order to stay secret
                     As a secret organization
                     We need to be able to erase past agents' memory
-                    DESC
-                    ,
+                    DESC,
                     [],
                     $background,
                     $scenarios,
@@ -122,7 +135,12 @@ public function translationTestDataProvider(): array
             $dumped = $dumper->dump($lang, false, true);
 
             foreach ($dumped as $num => $dumpedFeature) {
-                $data[$lang . '_' . $num] = [$lang, $num, $features[$num], $dumpedFeature];
+                yield $lang . '_' . $num => [
+                    'language' => $lang,
+                    'num' => $num,
+                    'etalon' => $features[$num],
+                    'source' => $dumpedFeature,
+                ];
             }
         }
 
@@ -130,23 +148,22 @@ public function translationTestDataProvider(): array
     }
 
     /**
-     * @dataProvider translationTestDataProvider
-     *
      * @param string $language language name
      * @param int $num Fixture index for that language
      * @param FeatureNode $etalon etalon features (to test against)
      * @param string $source gherkin source
      */
-    public function testTranslation($language, $num, FeatureNode $etalon, $source): void
+    #[DataProvider('translationTestDataProvider')]
+    public function testTranslation(string $language, int $num, FeatureNode $etalon, string $source): void
     {
-        $keywords = $this->getKeywords();
+        $keywords = static::getKeywords();
         $lexer = new Lexer($keywords);
         $parser = new Parser($lexer);
 
         try {
             $parsed = $parser->parse($source, __DIR__ . DIRECTORY_SEPARATOR . $language . '_' . ($num + 1) . '.feature');
-        } catch (Exception $e) {
-            throw new Exception($e->getMessage() . ":\n" . $source, 0, $e);
+        } catch (\Throwable $e) {
+            throw new \RuntimeException($e->getMessage() . ":\n" . $source, 0, $e);
         }
 
         $this->assertEquals($etalon, $parsed);
diff --git a/tests/Loader/ArrayLoaderTest.php b/tests/Loader/ArrayLoaderTest.php
index bc10ce49..81b9c58c 100644
--- a/tests/Loader/ArrayLoaderTest.php
+++ b/tests/Loader/ArrayLoaderTest.php
@@ -217,9 +217,9 @@ public function testLoadBackground(): void
 
         $this->assertFalse($features[0]->hasBackground());
         $this->assertTrue($features[1]->hasBackground());
-        $this->assertEquals(0, $features[1]->getBackground()->getLine());
+        $this->assertEquals(0, $features[1]->getBackground()?->getLine());
         $this->assertTrue($features[2]->hasBackground());
-        $this->assertEquals(2, $features[2]->getBackground()->getLine());
+        $this->assertEquals(2, $features[2]->getBackground()?->getLine());
     }
 
     public function testLoadSteps(): void
@@ -255,7 +255,7 @@ public function testLoadSteps(): void
         ]);
 
         $background = $features[0]->getBackground();
-        $this->assertTrue($background->hasSteps());
+        $this->assertTrue($background?->hasSteps());
         $this->assertCount(2, $background->getSteps());
         $steps = $background->getSteps();
         $this->assertEquals('Gangway!', $steps[0]->getType());
@@ -348,7 +348,7 @@ public function testLoadStepArguments(): void
 
         $background = $features[0]->getBackground();
 
-        $this->assertTrue($background->hasSteps());
+        $this->assertTrue($background?->hasSteps());
 
         $steps = $background->getSteps();
 
diff --git a/tests/Loader/DirectoryLoaderTest.php b/tests/Loader/DirectoryLoaderTest.php
index e6c9bd6e..37cf1f2e 100644
--- a/tests/Loader/DirectoryLoaderTest.php
+++ b/tests/Loader/DirectoryLoaderTest.php
@@ -10,42 +10,41 @@
 
 namespace Tests\Behat\Gherkin\Loader;
 
+use Behat\Gherkin\Gherkin;
 use Behat\Gherkin\Loader\DirectoryLoader;
+use Behat\Gherkin\Loader\GherkinFileLoader;
+use PHPUnit\Framework\MockObject\MockObject;
 use PHPUnit\Framework\TestCase;
 
 class DirectoryLoaderTest extends TestCase
 {
-    private $gherkin;
-    private $loader;
-    private $featuresPath;
+    private MockObject&Gherkin $gherkin;
+    private DirectoryLoader $loader;
+    private string $featuresPath;
 
     protected function setUp(): void
     {
         $this->gherkin = $this->createGherkinMock();
         $this->loader = new DirectoryLoader($this->gherkin);
 
-        $this->featuresPath = realpath(__DIR__ . '/../Fixtures/directories');
+        $this->featuresPath = (string) realpath(__DIR__ . '/../Fixtures/directories');
     }
 
-    protected function createGherkinMock()
+    protected function createGherkinMock(): MockObject&Gherkin
     {
-        $gherkin = $this->getMockBuilder('Behat\Gherkin\Gherkin')
+        return $this->getMockBuilder(Gherkin::class)
             ->disableOriginalConstructor()
             ->getMock();
-
-        return $gherkin;
     }
 
-    protected function createGherkinFileLoaderMock()
+    protected function createGherkinFileLoaderMock(): MockObject&GherkinFileLoader
     {
-        $loader = $this->getMockBuilder('Behat\Gherkin\Loader\GherkinFileLoader')
+        return $this->getMockBuilder(GherkinFileLoader::class)
             ->disableOriginalConstructor()
             ->getMock();
-
-        return $loader;
     }
 
-    public function testSupports()
+    public function testSupports(): void
     {
         $this->assertFalse($this->loader->supports('non-existent path'));
         $this->assertFalse($this->loader->supports('non-existent path:2'));
@@ -56,31 +55,31 @@ public function testSupports()
         $this->assertTrue($this->loader->supports(__DIR__ . '/../Fixtures/features'));
     }
 
-    public function testUndefinedFileLoad()
+    public function testUndefinedFileLoad(): void
     {
         $this->gherkin
             ->expects($this->once())
             ->method('resolveLoader')
             ->with($this->featuresPath . DIRECTORY_SEPARATOR . 'phps' . DIRECTORY_SEPARATOR . 'some_file.php')
-            ->will($this->returnValue(null));
+            ->willReturn(null);
 
         $this->assertEquals([], $this->loader->load($this->featuresPath . '/phps'));
     }
 
-    public function testBasePath()
+    public function testBasePath(): void
     {
         $this->gherkin
             ->expects($this->once())
             ->method('resolveLoader')
             ->with($this->featuresPath . DIRECTORY_SEPARATOR . 'phps' . DIRECTORY_SEPARATOR . 'some_file.php')
-            ->will($this->returnValue(null));
+            ->willReturn(null);
 
         $this->loader->setBasePath($this->featuresPath);
 
         $this->assertEquals([], $this->loader->load('phps'));
     }
 
-    public function testDefinedFileLoad()
+    public function testDefinedFileLoad(): void
     {
         $loaderMock = $this->createGherkinFileLoaderMock();
 
@@ -88,13 +87,13 @@ public function testDefinedFileLoad()
             ->expects($this->once())
             ->method('resolveLoader')
             ->with($this->featuresPath . DIRECTORY_SEPARATOR . 'phps' . DIRECTORY_SEPARATOR . 'some_file.php')
-            ->will($this->returnValue($loaderMock));
+            ->willReturn($loaderMock);
 
         $loaderMock
             ->expects($this->once())
             ->method('load')
             ->with($this->featuresPath . DIRECTORY_SEPARATOR . 'phps' . DIRECTORY_SEPARATOR . 'some_file.php')
-            ->will($this->returnValue(['feature1', 'feature2']));
+            ->willReturn(['feature1', 'feature2']);
 
         $this->assertEquals(['feature1', 'feature2'], $this->loader->load($this->featuresPath . '/phps'));
     }
diff --git a/tests/Loader/GherkinFileLoaderTest.php b/tests/Loader/GherkinFileLoaderTest.php
index 2e84ef4f..d7d22d0f 100644
--- a/tests/Loader/GherkinFileLoaderTest.php
+++ b/tests/Loader/GherkinFileLoaderTest.php
@@ -10,6 +10,7 @@
 
 namespace Tests\Behat\Gherkin\Loader;
 
+use Behat\Gherkin\Cache\CacheInterface;
 use Behat\Gherkin\Keywords\CucumberKeywords;
 use Behat\Gherkin\Lexer;
 use Behat\Gherkin\Loader\GherkinFileLoader;
@@ -18,13 +19,19 @@
 
 class GherkinFileLoaderTest extends TestCase
 {
-    /**
-     * @var GherkinFileLoader
-     */
-    private $loader;
-    private $featuresPath;
+    private GherkinFileLoader $loader;
+    private string $featuresPath;
 
-    public function testSupports()
+    protected function setUp(): void
+    {
+        $keywords = new CucumberKeywords(__DIR__ . '/../Fixtures/i18n.yml');
+        $parser = new Parser(new Lexer($keywords));
+        $this->loader = new GherkinFileLoader($parser);
+
+        $this->featuresPath = (string) realpath(__DIR__ . '/../Fixtures/features');
+    }
+
+    public function testSupports(): void
     {
         $this->assertFalse($this->loader->supports('non-existent path'));
         $this->assertFalse($this->loader->supports('non-existent path:2'));
@@ -35,7 +42,7 @@ public function testSupports()
         $this->assertTrue($this->loader->supports(__DIR__ . '/../Fixtures/features/pystring.feature'));
     }
 
-    public function testLoad()
+    public function testLoad(): void
     {
         $features = $this->loader->load($this->featuresPath . '/pystring.feature');
         $this->assertCount(1, $features);
@@ -48,15 +55,15 @@ public function testLoad()
         $this->assertEquals($this->featuresPath . DIRECTORY_SEPARATOR . 'multiline_name.feature', $features[0]->getFile());
     }
 
-    public function testParsingUncachedFeature()
+    public function testParsingUncachedFeature(): void
     {
-        $cache = $this->getMockBuilder('Behat\Gherkin\Cache\CacheInterface')->getMock();
+        $cache = $this->getMockBuilder(CacheInterface::class)->getMock();
         $this->loader->setCache($cache);
 
         $cache->expects($this->once())
             ->method('isFresh')
             ->with($path = $this->featuresPath . DIRECTORY_SEPARATOR . 'pystring.feature', filemtime($path))
-            ->will($this->returnValue(false));
+            ->willReturn(false);
 
         $cache->expects($this->once())
             ->method('write');
@@ -65,20 +72,20 @@ public function testParsingUncachedFeature()
         $this->assertCount(1, $features);
     }
 
-    public function testParsingCachedFeature()
+    public function testParsingCachedFeature(): void
     {
-        $cache = $this->getMockBuilder('Behat\Gherkin\Cache\CacheInterface')->getMock();
+        $cache = $this->getMockBuilder(CacheInterface::class)->getMock();
         $this->loader->setCache($cache);
 
         $cache->expects($this->once())
             ->method('isFresh')
             ->with($path = $this->featuresPath . DIRECTORY_SEPARATOR . 'pystring.feature', filemtime($path))
-            ->will($this->returnValue(true));
+            ->willReturn(true);
 
         $cache->expects($this->once())
             ->method('read')
             ->with($path)
-            ->will($this->returnValue('cache'));
+            ->willReturn('cache');
 
         $cache->expects($this->never())
             ->method('write');
@@ -87,7 +94,7 @@ public function testParsingCachedFeature()
         $this->assertEquals('cache', $features[0]);
     }
 
-    public function testBasePath()
+    public function testBasePath(): void
     {
         $this->assertFalse($this->loader->supports('features'));
         $this->assertFalse($this->loader->supports('tables.feature'));
@@ -108,13 +115,4 @@ public function testBasePath()
         $this->assertEquals('multiline', $features[0]->getTitle());
         $this->assertEquals(realpath($this->featuresPath . DIRECTORY_SEPARATOR . 'multiline_name.feature'), $features[0]->getFile());
     }
-
-    protected function setUp(): void
-    {
-        $keywords = new CucumberKeywords(__DIR__ . '/../Fixtures/i18n.yml');
-        $parser = new Parser(new Lexer($keywords));
-        $this->loader = new GherkinFileLoader($parser);
-
-        $this->featuresPath = realpath(__DIR__ . '/../Fixtures/features');
-    }
 }
diff --git a/tests/Node/TableNodeTest.php b/tests/Node/TableNodeTest.php
index 3a7432bb..af761047 100644
--- a/tests/Node/TableNodeTest.php
+++ b/tests/Node/TableNodeTest.php
@@ -16,7 +16,7 @@
 
 class TableNodeTest extends TestCase
 {
-    public function testConstructorExpectsSameNumberOfColumnsInEachRow()
+    public function testConstructorExpectsSameNumberOfColumnsInEachRow(): void
     {
         $this->expectException(NodeException::class);
         new TableNode([
@@ -26,35 +26,38 @@ public function testConstructorExpectsSameNumberOfColumnsInEachRow()
         ]);
     }
 
-    public function testConstructorExpectsTwoDimensionalArray()
+    public function testConstructorExpectsTwoDimensionalArray(): void
     {
         $this->expectException(NodeException::class);
         $this->expectExceptionMessage("Table row '0' is expected to be array, got string");
+
         new TableNode([
             'everzet', 'antono',
         ]);
     }
 
-    public function testConstructorExpectsScalarCellValue()
+    public function testConstructorExpectsScalarCellValue(): void
     {
         $this->expectException(NodeException::class);
         $this->expectExceptionMessage("Table cell at row '0', col '0' is expected to be scalar, got array");
+
         new TableNode([
             [['everzet', 'antono']],
         ]);
     }
 
-    public function testConstructorExpectsEqualRowLengths()
+    public function testConstructorExpectsEqualRowLengths(): void
     {
         $this->expectException(NodeException::class);
         $this->expectExceptionMessage("Table row '1' is expected to have 2 columns, got 1");
+
         new TableNode([
             ['everzet', 'antono'],
             ['everzet'],
         ]);
     }
 
-    public function testHashTable()
+    public function testHashTable(): void
     {
         $table = new TableNode([
             ['username', 'password'],
@@ -86,7 +89,7 @@ public function testHashTable()
         );
     }
 
-    public function testIterator()
+    public function testIterator(): void
     {
         $table = new TableNode([
             ['username', 'password'],
@@ -105,7 +108,7 @@ public function testIterator()
         );
     }
 
-    public function testRowsHashTable()
+    public function testRowsHashTable(): void
     {
         $table = new TableNode([
             ['username', 'everzet'],
@@ -119,7 +122,7 @@ public function testRowsHashTable()
         );
     }
 
-    public function testLongRowsHashTable()
+    public function testLongRowsHashTable(): void
     {
         $table = new TableNode([
             ['username', 'everzet', 'marcello'],
@@ -134,7 +137,7 @@ public function testLongRowsHashTable()
         ], $table->getRowsHash());
     }
 
-    public function testGetRows()
+    public function testGetRows(): void
     {
         $table = new TableNode([
             ['username', 'password'],
@@ -149,7 +152,7 @@ public function testGetRows()
         ], $table->getRows());
     }
 
-    public function testGetLines()
+    public function testGetLines(): void
     {
         $table = new TableNode([
             5 => ['username', 'password'],
@@ -160,7 +163,7 @@ public function testGetLines()
         $this->assertEquals([5, 10, 13], $table->getLines());
     }
 
-    public function testGetRow()
+    public function testGetRow(): void
     {
         $table = new TableNode([
             ['username', 'password'],
@@ -172,7 +175,7 @@ public function testGetRow()
         $this->assertEquals(['antono', 'pa$sword'], $table->getRow(2));
     }
 
-    public function testGetColumn()
+    public function testGetColumn(): void
     {
         $table = new TableNode([
             ['username', 'password'],
@@ -192,7 +195,7 @@ public function testGetColumn()
         $this->assertEquals(['username', 'everzet', 'antono'], $table->getColumn(0));
     }
 
-    public function testGetRowWithLineNumbers()
+    public function testGetRowWithLineNumbers(): void
     {
         $table = new TableNode([
             5 => ['username', 'password'],
@@ -204,7 +207,7 @@ public function testGetRowWithLineNumbers()
         $this->assertEquals(['antono', 'pa$sword'], $table->getRow(2));
     }
 
-    public function testGetTable()
+    public function testGetTable(): void
     {
         $table = new TableNode($a = [
             5 => ['username', 'password'],
@@ -215,7 +218,7 @@ public function testGetTable()
         $this->assertEquals($a, $table->getTable());
     }
 
-    public function testGetRowLine()
+    public function testGetRowLine(): void
     {
         $table = new TableNode([
             5 => ['username', 'password'],
@@ -227,7 +230,7 @@ public function testGetRowLine()
         $this->assertEquals(13, $table->getRowLine(2));
     }
 
-    public function testGetRowAsString()
+    public function testGetRowAsString(): void
     {
         $table = new TableNode([
             5 => ['username', 'password'],
@@ -239,7 +242,7 @@ public function testGetRowAsString()
         $this->assertEquals('| antono   | pa$sword |', $table->getRowAsString(2));
     }
 
-    public function testGetTableAsString()
+    public function testGetTableAsString(): void
     {
         $table = new TableNode([
             5 => ['id', 'username', 'password'],
@@ -255,7 +258,7 @@ public function testGetTableAsString()
         $this->assertEquals($expected, $table->getTableAsString());
     }
 
-    public function testFromList()
+    public function testFromList(): void
     {
         $table = TableNode::fromList([
             'everzet',
@@ -269,7 +272,7 @@ public function testFromList()
         $this->assertEquals($expected, $table);
     }
 
-    public function testMergeRowsFromTablePassSeveralTablesShouldBeMerged()
+    public function testMergeRowsFromTablePassSeveralTablesShouldBeMerged(): void
     {
         $table = new TableNode([
             5 => ['id', 'username', 'password'],
@@ -298,7 +301,7 @@ public function testMergeRowsFromTablePassSeveralTablesShouldBeMerged()
         $this->assertEquals(['32', '3antono', '3pa$sword'], $table->getRow(6));
     }
 
-    public function testMergeRowsFromTableWrongHeaderNameExceptionThrown()
+    public function testMergeRowsFromTableWrongHeaderNameExceptionThrown(): void
     {
         $this->expectException(NodeException::class);
         $table = new TableNode([
@@ -315,16 +318,17 @@ public function testMergeRowsFromTableWrongHeaderNameExceptionThrown()
         $table->mergeRowsFromTable($new);
     }
 
-    public function testGetTableFromListWithMultidimensionalArrayArgument()
+    public function testGetTableFromListWithMultidimensionalArrayArgument(): void
     {
         $this->expectException(NodeException::class);
+
         TableNode::fromList([
             [1, 2, 3],
             [4, 5, 6],
         ]);
     }
 
-    public function testMergeRowsFromTableWrongHeaderOrderExceptionThrown()
+    public function testMergeRowsFromTableWrongHeaderOrderExceptionThrown(): void
     {
         $this->expectException(NodeException::class);
         $table = new TableNode([
@@ -341,7 +345,7 @@ public function testMergeRowsFromTableWrongHeaderOrderExceptionThrown()
         $table->mergeRowsFromTable($new);
     }
 
-    public function testMergeRowsFromTableWrongHeaderSizeExceptionThrown()
+    public function testMergeRowsFromTableWrongHeaderSizeExceptionThrown(): void
     {
         $this->expectException(NodeException::class);
         $table = new TableNode([
diff --git a/tests/Node/TaggedNodeTraitTest.php b/tests/Node/TaggedNodeTraitTest.php
index 2762b7f3..3b4f4b2b 100644
--- a/tests/Node/TaggedNodeTraitTest.php
+++ b/tests/Node/TaggedNodeTraitTest.php
@@ -37,11 +37,17 @@ public function testHasTag(): void
         $this->assertFalse($node->hasTag('b'));
     }
 
+    /**
+     * @param list<string> $tags
+     */
     private function createTaggedNode(array $tags): TaggedNodeInterface
     {
         return new class($tags) implements TaggedNodeInterface {
             use TaggedNodeTrait;
 
+            /**
+             * @param list<string> $tags
+             */
             public function __construct(private readonly array $tags)
             {
             }
diff --git a/tests/ParserExceptionsTest.php b/tests/ParserExceptionsTest.php
index c58a6301..14c632f6 100644
--- a/tests/ParserExceptionsTest.php
+++ b/tests/ParserExceptionsTest.php
@@ -13,6 +13,8 @@
 use Behat\Gherkin\Exception\ParserException;
 use Behat\Gherkin\Keywords\ArrayKeywords;
 use Behat\Gherkin\Lexer;
+use Behat\Gherkin\Node\BackgroundNode;
+use Behat\Gherkin\Node\FeatureNode;
 use Behat\Gherkin\Parser;
 use PHPUnit\Framework\TestCase;
 
@@ -61,6 +63,7 @@ public function testStepRightAfterFeature(): void
 
         $parsed = $this->gherkin->parse($feature);
 
+        $this->assertInstanceOf(FeatureNode::class, $parsed);
         $this->assertEquals("\n  Given some step-like line", $parsed->getDescription());
     }
 
@@ -83,8 +86,10 @@ public function testTextInBackground(): void
         Scenario: bug user edit date
         GHERKIN;
 
-        $feature = $this->gherkin->parse($feature);
-        $background = $feature->getBackground();
+        $parsed = $this->gherkin->parse($feature);
+        $this->assertInstanceOf(FeatureNode::class, $parsed);
+        $background = $parsed->getBackground();
+        $this->assertInstanceOf(BackgroundNode::class, $background);
         $this->assertEquals(
             "remove X to cause bug\nStep is red form is not valid\nasd\nasd\nas\nda\nsd\nas\ndas\nd",
             $background->getTitle()
@@ -123,6 +128,7 @@ public function testTextInScenario(): void
         GHERKIN;
 
         $feature = $this->gherkin->parse($feature);
+        $this->assertInstanceOf(FeatureNode::class, $feature);
 
         $this->assertCount(2, $scenarios = $feature->getScenarios());
         $firstTitle = <<<'TEXT'
diff --git a/tests/ParserTest.php b/tests/ParserTest.php
index 12374291..441e3825 100644
--- a/tests/ParserTest.php
+++ b/tests/ParserTest.php
@@ -16,6 +16,7 @@
 use Behat\Gherkin\Lexer;
 use Behat\Gherkin\Loader\YamlFileLoader;
 use Behat\Gherkin\Node\FeatureNode;
+use Behat\Gherkin\Node\ScenarioNode;
 use Behat\Gherkin\Parser;
 use Exception;
 use PHPUnit\Framework\Attributes\DataProvider;
@@ -28,7 +29,7 @@ final class ParserTest extends TestCase
      */
     public static function parserTestDataProvider(): iterable
     {
-        foreach (glob(__DIR__ . '/Fixtures/etalons/*.yml') as $file) {
+        foreach (Filesystem::find(__DIR__ . '/Fixtures/etalons/*.yml') as $file) {
             $testname = basename($file, '.yml');
             yield $testname => ['fixtureName' => $testname];
         }
@@ -63,6 +64,7 @@ public function testParserResetsTagsBetweenFeatures(): void
             FEATURE
         );
 
+        $this->assertInstanceOf(FeatureNode::class, $feature2);
         $this->assertSame([], $feature2->getTags());
     }
 
@@ -76,9 +78,11 @@ public function testSingleCharacterStepSupport(): void
             FEATURE
         );
 
+        $this->assertInstanceOf(FeatureNode::class, $feature);
         $scenarios = $feature->getScenarios();
         $scenario = array_shift($scenarios);
 
+        $this->assertInstanceOf(ScenarioNode::class, $scenario);
         $this->assertCount(1, $scenario->getSteps());
     }
 
@@ -213,12 +217,12 @@ private function createYamlParser(): YamlFileLoader
 
     private function parseFixture(string $fixture): ?FeatureNode
     {
-        $file = __DIR__ . '/Fixtures/features/' . $fixture;
+        $file = __DIR__ . "/Fixtures/features/$fixture";
 
-        return $this->createGherkinParser()->parse(file_get_contents($file), $file);
+        return $this->createGherkinParser()->parse(Filesystem::readFile($file), $file);
     }
 
-    private function parseEtalon($etalon): FeatureNode
+    private function parseEtalon(string $etalon): FeatureNode
     {
         $features = $this->createYamlParser()->load(__DIR__ . '/Fixtures/etalons/' . $etalon);
         $feature = $features[0];


Reviews:
user: login: acoulton
body: Thanks @uuf6429 just a couple of comments
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-13T15:56:02Z
user: login: uuf6429
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-13T20:42:08Z
user: login: uuf6429
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-13T20:50:10Z
user: login: acoulton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T08:38:52Z
user: login: stof
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-14T10:41:25Z
user: login: uuf6429
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T11:05:30Z
user: login: uuf6429
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T11:07:29Z
user: login: uuf6429
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T11:08:09Z
user: login: uuf6429
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T18:05:57Z
user: login: uuf6429
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T18:16:18Z
user: login: uuf6429
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T18:41:12Z
user: login: uuf6429
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T19:13:02Z
user: login: uuf6429
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T20:40:25Z
user: login: acoulton
body: LGTM @uuf6429, thanks
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T07:41:33Z
user: login: stof
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-15T08:37:03Z
user: login: stof
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-15T08:38:27Z
user: login: acoulton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T08:44:56Z
user: login: acoulton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T08:47:27Z
user: login: uuf6429
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T20:40:25Z
user: login: uuf6429
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T20:44:28Z
user: login: uuf6429
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T20:47:55Z
user: login: uuf6429
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T20:59:38Z
user: login: acoulton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T19:57:51Z
user: login: uuf6429
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T21:24:38Z
user: login: acoulton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T21:27:54Z
user: login: stof
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-18T13:28:07Z
user: login: uuf6429
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-18T17:59:04Z
user: login: acoulton
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-19T08:28:22Z
user: login: uuf6429
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-19T08:46:12Z
user: login: uuf6429
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-19T08:57:46Z
user: login: acoulton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-19T11:03:26Z
user: login: acoulton
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-19T11:07:32Z
user: login: uuf6429
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-19T13:44:41Z
user: login: acoulton
body: Thanks @uuf6429 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-19T14:44:14Z
user: login: stof
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-29T10:27:57Z

Review comments:
diff_hunk: @@ -73,7 +73,7 @@ public function testBrokenCacheRead(): void
             'broken_feature',
             new FeatureNode(null, null, [], null, [], '', '', null, 1),
         );
-        $files = glob($this->path . '/**/*.feature.cache');
+        $files = glob($this->path . '/**/*.feature.cache') ?: [];
path: tests/Cache/FileCacheTest.php
user: login: acoulton
body: Might be easier to debug & more consistent with the `file_get_contents` fixes to extract the glob calls (here & ParserTest) to a method that throws if the glob returns false?
created_at: 2025-04-13T15:49:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,26 @@
+<?php
+
+/*
+ * This file is part of the Behat Gherkin Parser.
+ * (c) Konstantin Kudryashov <ever.zet@gmail.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Tests\Behat\Gherkin;
+
+use RuntimeException;
+
+trait FileReaderTrait
path: tests/FileReaderTrait.php
user: login: acoulton
body: I'd probably have made this a helper class with public static methods - curious about why you chose a trait here (I'm not saying it's wrong)?
created_at: 2025-04-13T15:50:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -14,6 +14,14 @@
  * Keywords holder interface.
  *
  * @author Konstantin Kudryashov <ever.zet@gmail.com>
+ *
+ * @phpstan-type TLanguage string
+ * @phpstan-type TKeywordsArray array{feature: string, background: string, scenario: string, scenario_outline: string, examples: string, given: string, when: string, then: string, and: string, but: string}
+ * @phpstan-type TMultiLanguageKeywords array<TLanguage, TKeywordsArray>
+ * @phpstan-type TKeywordsString string
+ * @phpstan-type TStepKeywordsType 'Given'|'When'|'Then'|'And'|'But'
+ * @phpstan-type TGeneralKeywordsType 'Feature'|'Background'|'Scenario'|'Outline'|'Examples'|'Step'
+ * @phpstan-type TKeywordsType TGeneralKeywordsType|TStepKeywordsType
path: src/Keywords/KeywordsInterface.php
user: login: acoulton
body: I'm not certain what these types are for - I didn't see them referenced anywhere in the PR. I might have missed them, but I don't think so?
created_at: 2025-04-13T15:55:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -14,6 +14,14 @@
  * Keywords holder interface.
  *
  * @author Konstantin Kudryashov <ever.zet@gmail.com>
+ *
+ * @phpstan-type TLanguage string
+ * @phpstan-type TKeywordsArray array{feature: string, background: string, scenario: string, scenario_outline: string, examples: string, given: string, when: string, then: string, and: string, but: string}
+ * @phpstan-type TMultiLanguageKeywords array<TLanguage, TKeywordsArray>
+ * @phpstan-type TKeywordsString string
+ * @phpstan-type TStepKeywordsType 'Given'|'When'|'Then'|'And'|'But'
+ * @phpstan-type TGeneralKeywordsType 'Feature'|'Background'|'Scenario'|'Outline'|'Examples'|'Step'
+ * @phpstan-type TKeywordsType TGeneralKeywordsType|TStepKeywordsType
path: src/Keywords/KeywordsInterface.php
user: login: uuf6429
body: You're right, only the first 3 are needed.
created_at: 2025-04-13T20:42:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,26 @@
+<?php
+
+/*
+ * This file is part of the Behat Gherkin Parser.
+ * (c) Konstantin Kudryashov <ever.zet@gmail.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Tests\Behat\Gherkin;
+
+use RuntimeException;
+
+trait FileReaderTrait
path: tests/FileReaderTrait.php
user: login: uuf6429
body: No particular reason.. even though traits theoretically make it more manageable.

I've also wondering introducing [thecodingmachine/safe](https://github.com/thecodingmachine/safe), at least for dev/testing... wdyt?

Other option is to have a functions file, similar to how safe does it.
created_at: 2025-04-13T20:50:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,26 @@
+<?php
+
+/*
+ * This file is part of the Behat Gherkin Parser.
+ * (c) Konstantin Kudryashov <ever.zet@gmail.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Tests\Behat\Gherkin;
+
+use RuntimeException;
+
+trait FileReaderTrait
path: tests/FileReaderTrait.php
user: login: acoulton
body: I hadn't seen that package before, thanks for the pointer. 

I think it might be slight overkill for us, and from a quick skim it looks like [maybe in trying to be general purpose, it doesn't actually handle all the cases you might expect](https://github.com/thecodingmachine/safe/issues/120)? If we wanted to use a package, I might just use specific things for the logic we need e.g. `symfony/filesystem` (which we already have for dev) rather than trying to wrap the whole of PHP?

Equally, I think it's completely fine to have a helper of our own - I noticed we're also using e.g. `file_get_contents` in some of the production classes, so we'd benefit from a thin wrapper we can ship as part of the package rather than adding a new runtime dependency? I might then make that a class rather than a trait, since each class uses different combinations of filesystem methods?
created_at: 2025-04-14T08:38:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -127,7 +138,11 @@ public static function badCucumberFeatures(): iterable
      */
     private static function getCucumberFeatures(string $folder): iterable
     {
-        foreach (new FilesystemIterator(self::TESTDATA_PATH . $folder) as $file) {
+        $fileIterator = new FilesystemIterator(self::TESTDATA_PATH . $folder);
+        /**
+         * @var iterable<string, SplFileInfo> $fileIterator
path: tests/Cucumber/CompatibilityTest.php
user: login: stof
body: isn't phpstan smart enough to infer it ?
created_at: 2025-04-14T10:37:21Z
author_association: MEMBER
diff_hunk: @@ -14,6 +14,14 @@
  * Keywords holder interface.
  *
  * @author Konstantin Kudryashov <ever.zet@gmail.com>
+ *
+ * @phpstan-type TLanguage string
path: src/Keywords/KeywordsInterface.php
user: login: stof
body: do we really need to create such alias instead of using `string` directly ?
created_at: 2025-04-14T10:38:13Z
author_association: MEMBER
diff_hunk: @@ -14,6 +14,14 @@
  * Keywords holder interface.
  *
  * @author Konstantin Kudryashov <ever.zet@gmail.com>
+ *
+ * @phpstan-type TLanguage string
path: src/Keywords/KeywordsInterface.php
user: login: uuf6429
body: By itself it's pointless, but when used in other structures, it should be helpful.

For instance, `TMultiLanguageKeywords` could be defined as `array<string, ...>`, in which case I find it harder to convey that the key is the language.

Besides, it would make it easier to transition to finer-grained types in the future, if such a thing exists (e.g. if we're able to define the format of the string).
created_at: 2025-04-14T11:05:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -127,7 +138,11 @@ public static function badCucumberFeatures(): iterable
      */
     private static function getCucumberFeatures(string $folder): iterable
     {
-        foreach (new FilesystemIterator(self::TESTDATA_PATH . $folder) as $file) {
+        $fileIterator = new FilesystemIterator(self::TESTDATA_PATH . $folder);
+        /**
+         * @var iterable<string, SplFileInfo> $fileIterator
path: tests/Cucumber/CompatibilityTest.php
user: login: uuf6429
body: I'll double check, but I think I had to do it specifically because it couldn't.

If I may hazard a guess, maybe there might be something missing/wrong in the PHPStan stubs.
created_at: 2025-04-14T11:07:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,26 @@
+<?php
+
+/*
+ * This file is part of the Behat Gherkin Parser.
+ * (c) Konstantin Kudryashov <ever.zet@gmail.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Tests\Behat\Gherkin;
+
+use RuntimeException;
+
+trait FileReaderTrait
path: tests/FileReaderTrait.php
user: login: uuf6429
body: I'm also fine with a static class approach, I'll go for that.
created_at: 2025-04-14T11:08:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,26 @@
+<?php
+
+/*
+ * This file is part of the Behat Gherkin Parser.
+ * (c) Konstantin Kudryashov <ever.zet@gmail.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Tests\Behat\Gherkin;
+
+use RuntimeException;
+
+trait FileReaderTrait
path: tests/FileReaderTrait.php
user: login: uuf6429
body: I realised that we already have `symfony/filesystem` as a dev dependency, whereas `symfony/finder` is also included, but not as an explicit dependency, so might as well use both instead.
created_at: 2025-04-14T18:05:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -127,7 +138,11 @@ public static function badCucumberFeatures(): iterable
      */
     private static function getCucumberFeatures(string $folder): iterable
     {
-        foreach (new FilesystemIterator(self::TESTDATA_PATH . $folder) as $file) {
+        $fileIterator = new FilesystemIterator(self::TESTDATA_PATH . $folder);
+        /**
+         * @var iterable<string, SplFileInfo> $fileIterator
path: tests/Cucumber/CompatibilityTest.php
user: login: uuf6429
body: Without the type hinting, it looks like so: https://phpstan.org/r/79bcb4e0-f549-4723-a387-5a190948134c

This seems to be a known issue: https://github.com/phpstan/phpstan/issues/8093.
created_at: 2025-04-14T18:16:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,26 @@
+<?php
+
+/*
+ * This file is part of the Behat Gherkin Parser.
+ * (c) Konstantin Kudryashov <ever.zet@gmail.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Tests\Behat\Gherkin;
+
+use RuntimeException;
+
+trait FileReaderTrait
path: tests/FileReaderTrait.php
user: login: uuf6429
body: Damnit... `symfony/filesystem` introduced `readFile` in v7.1, which supports PHP 8.2+, but we want to support PHP 8.1 as well. 😩 
created_at: 2025-04-14T18:41:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -15,8 +15,11 @@
 use Behat\Gherkin\Node\FeatureNode;
 use Behat\Gherkin\Node\ScenarioNode;
 use PHPUnit\Framework\TestCase;
-use Symfony\Component\Filesystem\Filesystem;
+use Tests\Behat\Gherkin\Filesystem;
 
+/**
+ * @todo Use VFS
path: tests/Cache/FileCacheTest.php
user: login: uuf6429
body: @acoulton I will handle this and the other case in a new PR ASAP.
created_at: 2025-04-14T19:13:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -15,8 +15,11 @@
 use Behat\Gherkin\Node\FeatureNode;
 use Behat\Gherkin\Node\ScenarioNode;
 use PHPUnit\Framework\TestCase;
-use Symfony\Component\Filesystem\Filesystem;
+use Tests\Behat\Gherkin\Filesystem;
 
+/**
+ * @todo Use VFS
path: tests/Cache/FileCacheTest.php
user: login: uuf6429
body: Done: https://github.com/Behat/Gherkin/pull/298
created_at: 2025-04-14T20:40:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -55,9 +55,9 @@ jobs:
           ini-file: "development"
           coverage: none
 
-      - name: Update Symfony version
+      - name: Update symfony/yaml version
         if: matrix.symfony-version != ''
-        run: composer require --no-update "symfony/symfony:${{ matrix.symfony-version }}"
+        run: composer require --dev "symfony/yaml:${{ matrix.symfony-version }}"
path: .github/workflows/build.yml
user: login: stof
body: I would keep the `--no-update` so that dependencies are resolved in the next step with the expected flags.

Alternatively, a better implementation might be to use `symfony/flex` to restrict the versions of Symfony components, while preserving the original requirements in the composer.json. It is more robust.
created_at: 2025-04-15T08:34:10Z
author_association: MEMBER
diff_hunk: @@ -27,11 +30,6 @@ protected function setUp(): void
         $this->cache = new FileCache($this->path = sys_get_temp_dir() . uniqid('/gherkin-test'));
     }
 
-    protected function tearDown(): void
-    {
-        (new Filesystem())->remove($this->path);
path: tests/Cache/FileCacheTest.php
user: login: stof
body: why removing those lines ?
created_at: 2025-04-15T08:35:25Z
author_association: MEMBER
diff_hunk: @@ -11,23 +11,27 @@
 namespace Tests\Behat\Gherkin\Keywords;
 
 use Behat\Gherkin\Keywords\CucumberKeywords;
+use Behat\Gherkin\Keywords\KeywordsInterface;
 use Behat\Gherkin\Node\StepNode;
 use Symfony\Component\Yaml\Exception\ParseException;
 use Symfony\Component\Yaml\Yaml;
+use Tests\Behat\Gherkin\Filesystem;
 
 class CucumberKeywordsTest extends KeywordsTestCase
 {
-    protected function getKeywords()
+    protected static function getKeywords(): KeywordsInterface
     {
         return new CucumberKeywords(__DIR__ . '/../Fixtures/i18n.yml');
     }
 
-    protected function getKeywordsArray()
+    protected static function getKeywordsArray(): array
     {
-        return Yaml::parse(file_get_contents(__DIR__ . '/../Fixtures/i18n.yml'));
+        $data = Filesystem::readFile(__DIR__ . '/../Fixtures/i18n.yml');
+
+        return Yaml::parse($data);
path: tests/Keywords/CucumberKeywordsTest.php
user: login: stof
body: you could use `Yaml::parseFile()` instead (which also mentions the file name in the error message, as it knows it)
created_at: 2025-04-15T08:36:18Z
author_association: MEMBER
diff_hunk: @@ -74,14 +71,7 @@ public function testBrokenCacheRead(): void
             'broken_feature',
             new FeatureNode(null, null, [], null, [], '', '', null, 1),
         );
-        $files = iterator_to_array(
-            (new Finder())
-                ->in($this->path)
-                ->depth(1)
-                ->name('*.feature.cache')
-                ->files(),
-            false
-        );
+        $files = Filesystem::find("$this->path/**/*.feature.cache");
path: tests/Cache/FileCacheTest.php
user: login: stof
body: this `**` in the glob does not match the `->depth(1)` config of the Finder.
created_at: 2025-04-15T08:38:27Z
author_association: MEMBER
diff_hunk: @@ -74,14 +71,7 @@ public function testBrokenCacheRead(): void
             'broken_feature',
             new FeatureNode(null, null, [], null, [], '', '', null, 1),
         );
-        $files = iterator_to_array(
-            (new Finder())
-                ->in($this->path)
-                ->depth(1)
-                ->name('*.feature.cache')
-                ->files(),
-            false
-        );
+        $files = Filesystem::find("$this->path/**/*.feature.cache");
path: tests/Cache/FileCacheTest.php
user: login: acoulton
body: True - but arguably for this test it is safer to assert that the previous `->write` has only created one file at any depth. Ultimately we're trying to make sure that the file we modify below is the one the cache is expecting to read.
created_at: 2025-04-15T08:44:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -27,11 +30,6 @@ protected function setUp(): void
         $this->cache = new FileCache($this->path = sys_get_temp_dir() . uniqid('/gherkin-test'));
     }
 
-    protected function tearDown(): void
-    {
-        (new Filesystem())->remove($this->path);
path: tests/Cache/FileCacheTest.php
user: login: acoulton
body: I assume because symfony/filesystem is removed and there's not much point in implementing our own directory removal since they become redundant with #298
created_at: 2025-04-15T08:47:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -27,11 +30,6 @@ protected function setUp(): void
         $this->cache = new FileCache($this->path = sys_get_temp_dir() . uniqid('/gherkin-test'));
     }
 
-    protected function tearDown(): void
-    {
-        (new Filesystem())->remove($this->path);
path: tests/Cache/FileCacheTest.php
user: login: uuf6429
body: Correct.
created_at: 2025-04-15T20:40:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -74,14 +71,7 @@ public function testBrokenCacheRead(): void
             'broken_feature',
             new FeatureNode(null, null, [], null, [], '', '', null, 1),
         );
-        $files = iterator_to_array(
-            (new Finder())
-                ->in($this->path)
-                ->depth(1)
-                ->name('*.feature.cache')
-                ->files(),
-            false
-        );
+        $files = Filesystem::find("$this->path/**/*.feature.cache");
path: tests/Cache/FileCacheTest.php
user: login: uuf6429
body: I'm not sure why we're even discussing this since I introduced the finder implementation in one commit (instead of glob with **) and then changed it back (to **) in a subsequent one. 🤔 (`depth(1)` was a mistake on my side)
created_at: 2025-04-15T20:44:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -11,23 +11,27 @@
 namespace Tests\Behat\Gherkin\Keywords;
 
 use Behat\Gherkin\Keywords\CucumberKeywords;
+use Behat\Gherkin\Keywords\KeywordsInterface;
 use Behat\Gherkin\Node\StepNode;
 use Symfony\Component\Yaml\Exception\ParseException;
 use Symfony\Component\Yaml\Yaml;
+use Tests\Behat\Gherkin\Filesystem;
 
 class CucumberKeywordsTest extends KeywordsTestCase
 {
-    protected function getKeywords()
+    protected static function getKeywords(): KeywordsInterface
     {
         return new CucumberKeywords(__DIR__ . '/../Fixtures/i18n.yml');
     }
 
-    protected function getKeywordsArray()
+    protected static function getKeywordsArray(): array
     {
-        return Yaml::parse(file_get_contents(__DIR__ . '/../Fixtures/i18n.yml'));
+        $data = Filesystem::readFile(__DIR__ . '/../Fixtures/i18n.yml');
+
+        return Yaml::parse($data);
path: tests/Keywords/CucumberKeywordsTest.php
user: login: uuf6429
body: Cool, also works with yaml v5.4.
created_at: 2025-04-15T20:47:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -55,9 +55,9 @@ jobs:
           ini-file: "development"
           coverage: none
 
-      - name: Update Symfony version
+      - name: Update symfony/yaml version
         if: matrix.symfony-version != ''
-        run: composer require --no-update "symfony/symfony:${{ matrix.symfony-version }}"
+        run: composer require --dev "symfony/yaml:${{ matrix.symfony-version }}"
path: .github/workflows/build.yml
user: login: uuf6429
body: Ah, if I understand right, we just want to change the composer.json file in this step and leave the installation (and update) to the next step instead. Fine, makes sense to me.

I'm not sure how `symfony/flex` would make any difference though.. I think I'd rather keep it standard - `symfony/yaml` could have been any other random package we wanted to test with (that's also why I removed the original `symfony/symfony`dependency).
created_at: 2025-04-15T20:59:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -55,9 +55,9 @@ jobs:
           ini-file: "development"
           coverage: none
 
-      - name: Update Symfony version
+      - name: Update symfony/yaml version
         if: matrix.symfony-version != ''
-        run: composer require --no-update "symfony/symfony:${{ matrix.symfony-version }}"
+        run: composer require --dev "symfony/yaml:${{ matrix.symfony-version }}"
path: .github/workflows/build.yml
user: login: acoulton
body: To be honest, since (I think? Certainly as far as the composer.json indicates)  we are only using the Symfony components in our test suite I'm not actually sure we need to support/build with multiple versions? Can't we just pick one that works with our php versions and use that?
created_at: 2025-04-17T19:57:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -55,9 +55,9 @@ jobs:
           ini-file: "development"
           coverage: none
 
-      - name: Update Symfony version
+      - name: Update symfony/yaml version
         if: matrix.symfony-version != ''
-        run: composer require --no-update "symfony/symfony:${{ matrix.symfony-version }}"
+        run: composer require --dev "symfony/yaml:${{ matrix.symfony-version }}"
path: .github/workflows/build.yml
user: login: uuf6429
body: @acoulton `symfony/yaml` is an optional dependency (check the "suggest" in `composer.json`) to parse features from yaml files.

In addition to unrelated tests, the pipeline is testing against each major version of that component.

IMO setting a version for `symfony/symfony` or using `symfony/flex` would make this more confusing and less standard.
created_at: 2025-04-17T21:24:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -55,9 +55,9 @@ jobs:
           ini-file: "development"
           coverage: none
 
-      - name: Update Symfony version
+      - name: Update symfony/yaml version
         if: matrix.symfony-version != ''
-        run: composer require --no-update "symfony/symfony:${{ matrix.symfony-version }}"
+        run: composer require --dev "symfony/yaml:${{ matrix.symfony-version }}"
path: .github/workflows/build.yml
user: login: acoulton
body: Ah, I missed that. In that case yes I agree, better to just vary the symfony/yaml direct in this instance. symfony/flex would only be useful if we had multiple Symfony components that need to switch versions IMO.
created_at: 2025-04-17T21:27:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -55,9 +55,9 @@ jobs:
           ini-file: "development"
           coverage: none
 
-      - name: Update Symfony version
+      - name: Update symfony/yaml version
         if: matrix.symfony-version != ''
-        run: composer require --no-update "symfony/symfony:${{ matrix.symfony-version }}"
+        run: composer require --dev "symfony/yaml:${{ matrix.symfony-version }}"
path: .github/workflows/build.yml
user: login: stof
body: The drawback of this approach is that it replaces entirely the requirement in our composer.json, which would ignore it.

Thinking about it, this could be also solved by using the `--with` flag in the `composer update` command to apply an additional temporary constraint in the solver (which composer will validate against our normal requirement instead of ignoring the normal requirement entirely)
created_at: 2025-04-18T13:28:07Z
author_association: MEMBER
diff_hunk: @@ -55,9 +55,9 @@ jobs:
           ini-file: "development"
           coverage: none
 
-      - name: Update Symfony version
+      - name: Update symfony/yaml version
         if: matrix.symfony-version != ''
-        run: composer require --no-update "symfony/symfony:${{ matrix.symfony-version }}"
+        run: composer require --dev "symfony/yaml:${{ matrix.symfony-version }}"
path: .github/workflows/build.yml
user: login: uuf6429
body: @stof now I see what you mean. In that case we can simplify the pipeline further: https://github.com/Behat/Gherkin/pull/297/commits/238553a236e99ecc942aa5bb09047d867d1b0316
I also took the liberty to make it track and submit coverage, which seems to work well:
https://app.codecov.io/gh/Behat/Gherkin/tree/uuf6429%2FGherkin%3Achore%2Fphpstan-level-9-tests
created_at: 2025-04-18T17:59:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -39,28 +39,25 @@ jobs:
       matrix:
         php: [ 8.1, 8.2, 8.3, 8.4 ]
         composer-flags: [ "" ]
-        symfony-version: [ "" ]
         include:
           - php: 8.1
-            symfony-version: '^5.4'
+            composer-flags: "--with=symfony/yaml:^5.4"
           - php: 8.1
-            symfony-version: '^6.4'
+            composer-flags: "--with=symfony/yaml:^6.4"
     steps:
       - uses: actions/checkout@v4
-
-      - name: Setup PHP
-        uses: shivammathur/setup-php@v2
+      - uses: shivammathur/setup-php@v2
path: .github/workflows/build.yml
user: login: acoulton
body: I would prefer to keep the blank line between each step, I think that's more readable.
created_at: 2025-04-19T08:19:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -39,28 +39,25 @@ jobs:
       matrix:
         php: [ 8.1, 8.2, 8.3, 8.4 ]
         composer-flags: [ "" ]
-        symfony-version: [ "" ]
         include:
           - php: 8.1
-            symfony-version: '^5.4'
+            composer-flags: "--with=symfony/yaml:^5.4"
           - php: 8.1
-            symfony-version: '^6.4'
+            composer-flags: "--with=symfony/yaml:^6.4"
path: .github/workflows/build.yml
user: login: acoulton
body: Good solution, thanks
created_at: 2025-04-19T08:20:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -39,28 +39,25 @@ jobs:
       matrix:
         php: [ 8.1, 8.2, 8.3, 8.4 ]
         composer-flags: [ "" ]
-        symfony-version: [ "" ]
         include:
           - php: 8.1
-            symfony-version: '^5.4'
+            composer-flags: "--with=symfony/yaml:^5.4"
           - php: 8.1
-            symfony-version: '^6.4'
+            composer-flags: "--with=symfony/yaml:^6.4"
     steps:
       - uses: actions/checkout@v4
-
-      - name: Setup PHP
-        uses: shivammathur/setup-php@v2
+      - uses: shivammathur/setup-php@v2
         with:
           php-version: "${{ matrix.php }}"
           ini-file: "development"
-          coverage: none
-
-      - name: Update Symfony version
-        if: matrix.symfony-version != ''
-        run: composer require --no-update "symfony/symfony:${{ matrix.symfony-version }}"
-
-      - name: Install dependencies
-        run: composer update ${{ matrix.composer-flags }}
-
-      - name: Run tests (phpunit)
-        run: ./vendor/bin/phpunit
+          coverage: "xdebug"
+      - run: composer update ${{ matrix.composer-flags }}
+      - run: ./vendor/bin/phpunit --coverage-clover=coverage.xml --log-junit junit.xml --colors=always --testdox
+      - uses: codecov/codecov-action@v4
+        with:
+          token: ${{ secrets.CODECOV_TOKEN }}
path: .github/workflows/build.yml
user: login: acoulton
body: There is no `CODECOV_TOKEN` configured in this repository. The build output says `Fork detected, tokenless uploading used` so I assume it's ignoring this parameter at the moment but I guess it may not work once it merges to master? That secret also wouldn't be available in PR builds.

I'm also not clear on whether / how we need to set up codecov for ourselves - presumably someone needs to create an "official" behat profile on codecov to be able to control which repos can upload data etc? From a quick look at the codecov actions it looks like they support OIDC (e.g. GITHUB_TOKEN) permissions which IMO would be preferable to a repository secret. However since that means granting `id_token:write` to the github action, we should really split the codecov upload into a separate job (using artifacts from the main build).

It might be better to figure this out in a separate PR so we can get this merged? 
created_at: 2025-04-19T08:27:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -39,28 +39,25 @@ jobs:
       matrix:
         php: [ 8.1, 8.2, 8.3, 8.4 ]
         composer-flags: [ "" ]
-        symfony-version: [ "" ]
         include:
           - php: 8.1
-            symfony-version: '^5.4'
+            composer-flags: "--with=symfony/yaml:^5.4"
           - php: 8.1
-            symfony-version: '^6.4'
+            composer-flags: "--with=symfony/yaml:^6.4"
     steps:
       - uses: actions/checkout@v4
-
-      - name: Setup PHP
-        uses: shivammathur/setup-php@v2
+      - uses: shivammathur/setup-php@v2
         with:
           php-version: "${{ matrix.php }}"
           ini-file: "development"
-          coverage: none
-
-      - name: Update Symfony version
-        if: matrix.symfony-version != ''
-        run: composer require --no-update "symfony/symfony:${{ matrix.symfony-version }}"
-
-      - name: Install dependencies
-        run: composer update ${{ matrix.composer-flags }}
-
-      - name: Run tests (phpunit)
-        run: ./vendor/bin/phpunit
+          coverage: "xdebug"
+      - run: composer update ${{ matrix.composer-flags }}
+      - run: ./vendor/bin/phpunit --coverage-clover=coverage.xml --log-junit junit.xml --colors=always --testdox
+      - uses: codecov/codecov-action@v4
+        with:
+          token: ${{ secrets.CODECOV_TOKEN }}
path: .github/workflows/build.yml
user: login: uuf6429
body: I've copied that part from existing projects where it already works, and seeing as it did work (notice the `https://app.codecov.io/gh/Behat` URL), I think it should be fine to merge this as is (especially since I wouldn't change anything in the way it works).

Of course if it gets any more complicated, it would make sense to split it.. but so far I don't see any reason to.
created_at: 2025-04-19T08:46:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -39,28 +39,25 @@ jobs:
       matrix:
         php: [ 8.1, 8.2, 8.3, 8.4 ]
         composer-flags: [ "" ]
-        symfony-version: [ "" ]
         include:
           - php: 8.1
-            symfony-version: '^5.4'
+            composer-flags: "--with=symfony/yaml:^5.4"
           - php: 8.1
-            symfony-version: '^6.4'
+            composer-flags: "--with=symfony/yaml:^6.4"
     steps:
       - uses: actions/checkout@v4
-
-      - name: Setup PHP
-        uses: shivammathur/setup-php@v2
+      - uses: shivammathur/setup-php@v2
path: .github/workflows/build.yml
user: login: uuf6429
body: I mainly did that to be consistent with the other jobs.

However, I find it less readable with the blank lines.. this is essentially a build script, so it's kinda weird to have each line in a script separate by a blank line. I would rather leave that to separate jobs.

Additionally, I also remove the step names since they were mostly repeating what the step did almost verbatim.
created_at: 2025-04-19T08:57:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -39,28 +39,25 @@ jobs:
       matrix:
         php: [ 8.1, 8.2, 8.3, 8.4 ]
         composer-flags: [ "" ]
-        symfony-version: [ "" ]
         include:
           - php: 8.1
-            symfony-version: '^5.4'
+            composer-flags: "--with=symfony/yaml:^5.4"
           - php: 8.1
-            symfony-version: '^6.4'
+            composer-flags: "--with=symfony/yaml:^6.4"
     steps:
       - uses: actions/checkout@v4
-
-      - name: Setup PHP
-        uses: shivammathur/setup-php@v2
+      - uses: shivammathur/setup-php@v2
path: .github/workflows/build.yml
user: login: acoulton
body: No issue with removing the step names.

The difference with a build script is you would usually indent arguments to a command that spanned multiple lines. With an actions yaml file they are on the same indentation so the only marker for the start of a new step is the `-` at the start of the line. Not a huge issue if things are just a one-line `run` but as soon as you need to introduce `if`, `env`, etc then it becomes harder to see imo.
created_at: 2025-04-19T11:03:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -39,28 +39,25 @@ jobs:
       matrix:
         php: [ 8.1, 8.2, 8.3, 8.4 ]
         composer-flags: [ "" ]
-        symfony-version: [ "" ]
         include:
           - php: 8.1
-            symfony-version: '^5.4'
+            composer-flags: "--with=symfony/yaml:^5.4"
           - php: 8.1
-            symfony-version: '^6.4'
+            composer-flags: "--with=symfony/yaml:^6.4"
     steps:
       - uses: actions/checkout@v4
-
-      - name: Setup PHP
-        uses: shivammathur/setup-php@v2
+      - uses: shivammathur/setup-php@v2
         with:
           php-version: "${{ matrix.php }}"
           ini-file: "development"
-          coverage: none
-
-      - name: Update Symfony version
-        if: matrix.symfony-version != ''
-        run: composer require --no-update "symfony/symfony:${{ matrix.symfony-version }}"
-
-      - name: Install dependencies
-        run: composer update ${{ matrix.composer-flags }}
-
-      - name: Run tests (phpunit)
-        run: ./vendor/bin/phpunit
+          coverage: "xdebug"
+      - run: composer update ${{ matrix.composer-flags }}
+      - run: ./vendor/bin/phpunit --coverage-clover=coverage.xml --log-junit junit.xml --colors=always --testdox
+      - uses: codecov/codecov-action@v4
+        with:
+          token: ${{ secrets.CODECOV_TOKEN }}
path: .github/workflows/build.yml
user: login: acoulton
body: So codecov allow completely unauthenticated upload?

If so and the token is not actually required then it would be better to remove the `token: ${{ secrets.CODECOV_TOKEN }}` lines to avoid confusion. Not least then if it stops working in future it will be clearer that something on codecov has changed rather than trying to understand why the secret (which never existed) is undefined.

Also I noticed on https://app.codecov.io/gh/Behat/Gherkin/commit/238553a236e99ecc942aa5bb09047d867d1b0316 it reports "6 flag errors" - all appear to be "Multiple flags detected. Please ensure one flag per upload." Not sure what that's about, do we need to do more to identify/separate the different coverage reports from each actions job?
created_at: 2025-04-19T11:07:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -39,28 +39,25 @@ jobs:
       matrix:
         php: [ 8.1, 8.2, 8.3, 8.4 ]
         composer-flags: [ "" ]
-        symfony-version: [ "" ]
         include:
           - php: 8.1
-            symfony-version: '^5.4'
+            composer-flags: "--with=symfony/yaml:^5.4"
           - php: 8.1
-            symfony-version: '^6.4'
+            composer-flags: "--with=symfony/yaml:^6.4"
     steps:
       - uses: actions/checkout@v4
-
-      - name: Setup PHP
-        uses: shivammathur/setup-php@v2
+      - uses: shivammathur/setup-php@v2
path: .github/workflows/build.yml
user: login: uuf6429
body: I think I'd still keep it like that to encourage a 'good' habit in the sense that readers should expect the dash to indicate a new command, rather than a blank line which could mean anything (keep in mind that a long string could be split into multiple lines, including blank lines).
```yaml
- run: |
    this is the

    same command
```
Although my main point would be that conciseness in terms of whitespace tends to be more understandable and show the bigger picture.
created_at: 2025-04-19T13:44:41Z
author_association: CONTRIBUTOR

PR comments:
user: login: codecov-commenter
created_at: 2025-04-18T17:57:12Z
body: ## Welcome to [Codecov](https://codecov.io?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Behat) :tada:

Once you merge this PR into your default branch, you're all set! Codecov will compare coverage reports and display results in all future pull requests.

Thanks for integrating Codecov - We've got you covered :open_umbrella:
author_association: NONE
user: login: uuf6429
created_at: 2025-04-19T13:48:02Z
body: I've removed pipeline/workflow changes so we can get this PR merged ASAP (I'll resubmit the changes separately).
author_association: CONTRIBUTOR
user: login: uuf6429
created_at: 2025-04-26T15:06:15Z
body: @carlos-granados @stof could one of you please give this PR a quick review?

It's kinda blocking the rest of the upcoming changes.
author_association: CONTRIBUTOR
user: login: uuf6429
created_at: 2025-04-29T11:35:59Z
body: Thanks!
author_association: CONTRIBUTOR