From c381f5e69984db2db330af065c3bd108f98ccd8a Mon Sep 17 00:00:00 2001 From: Carlo Gadducci <50781338+wanted80@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:27:46 +0200 Subject: [PATCH 1/3] feat: allow configuring Pest configuration discovery paths Add optional pest.configPaths with a nullable list schema and wire it into PestFileDiscoverer. Explicit paths replace analysis paths and the automatic working-directory scan, allowing projects to avoid large data directories and mounted storage during Pest configuration discovery. Preserve existing discovery when unset, retain ancestor configuration and memoization, and allow an empty list to disable configuration discovery. Cover restricted and multiple paths, empty lists, default behavior, and PHPStan dependency injection with regression tests. --- extension.neon | 8 ++++ src/Type/Pest/PestFileDiscoverer.php | 6 ++- tests/Type/Fixtures/pest-discovery.neon | 4 ++ tests/Type/PestFileDiscovererTest.php | 50 +++++++++++++++++++++++++ 4 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 tests/Type/Fixtures/pest-discovery.neon diff --git a/extension.neon b/extension.neon index ac7f226..dc73a43 100644 --- a/extension.neon +++ b/extension.neon @@ -1,4 +1,6 @@ parameters: + pest: + configPaths: null universalObjectCratesClasses: - PHPUnit\Framework\TestCase scanDirectories: @@ -6,12 +8,18 @@ parameters: stubFiles: - stubs/DescribeCall.stub +parametersSchema: + pest: structure([ + configPaths: schema(listOf(string()), nullable()) + ]) + services: - class: Pest\PHPStan\Type\Pest\PestFileDiscoverer arguments: scanPaths: %paths% rootDir: %currentWorkingDirectory% + configPaths: %pest.configPaths% - class: Pest\PHPStan\Type\Pest\PestConfigReader diff --git a/src/Type/Pest/PestFileDiscoverer.php b/src/Type/Pest/PestFileDiscoverer.php index 6c7aa34..7383ca7 100644 --- a/src/Type/Pest/PestFileDiscoverer.php +++ b/src/Type/Pest/PestFileDiscoverer.php @@ -30,10 +30,12 @@ final class PestFileDiscoverer /** * @param string[] $scanPaths PHPStan's configured analysis paths + * @param string[]|null $configPaths Explicit paths for Pest.php discovery, or null to use analysis paths and the project root */ public function __construct( private readonly array $scanPaths, private readonly string $rootDir = '', + private readonly ?array $configPaths = null, ) { $this->parser = (new ParserFactory)->createForNewestSupportedVersion(); } @@ -49,9 +51,9 @@ public function discoverPestFiles(): array $files = []; - $scanPaths = $this->scanPaths; + $scanPaths = $this->configPaths ?? $this->scanPaths; - if ($this->rootDir !== '') { + if ($this->configPaths === null && $this->rootDir !== '') { $scanPaths[] = $this->rootDir; } diff --git a/tests/Type/Fixtures/pest-discovery.neon b/tests/Type/Fixtures/pest-discovery.neon new file mode 100644 index 0000000..65203d0 --- /dev/null +++ b/tests/Type/Fixtures/pest-discovery.neon @@ -0,0 +1,4 @@ +parameters: + pest: + configPaths: + - %currentWorkingDirectory%/tests/Type/Fixtures/pesthook-scope diff --git a/tests/Type/PestFileDiscovererTest.php b/tests/Type/PestFileDiscovererTest.php index 2479eaf..bd3c550 100644 --- a/tests/Type/PestFileDiscovererTest.php +++ b/tests/Type/PestFileDiscovererTest.php @@ -5,6 +5,7 @@ namespace Tests\Type; use Pest\PHPStan\Type\Pest\PestFileDiscoverer; +use PHPStan\DependencyInjection\ContainerFactory; use RuntimeException; $fixtureDir = static function (string $name): string { @@ -72,3 +73,52 @@ ->and(basename($pestFile))->toBe('Pest.php'); } }); + +test('explicit configuration paths do not search analysis paths or the project root', function () use ($fixtureDir): void { + $dir = $fixtureDir('pesthook-scope'); + $outside = $fixtureDir('pestconfig-matrix'); + $instance = new PestFileDiscoverer([$outside], dirname(__DIR__, 2), [$dir]); + + expect($instance->isPestConfigFile($dir.'/Pest.php'))->toBeTrue() + ->and($instance->isPestConfigFile($outside.'/Pest.php'))->toBeFalse() + ->and($instance->isPestConfigFile(__DIR__.'/../Pest.php'))->toBeTrue(); +}); + +test('explicit configuration paths support multiple test directories', function () use ($fixtureDir): void { + $first = $fixtureDir('pesthook-scope'); + $second = $fixtureDir('pestconfig-matrix'); + $instance = new PestFileDiscoverer([], '', [$first, $second]); + + expect($instance->isPestConfigFile($first.'/Pest.php'))->toBeTrue() + ->and($instance->isPestConfigFile($second.'/Pest.php'))->toBeTrue(); +}); + +test('an empty explicit configuration path list disables discovery', function () use ($fixtureDir): void { + $dir = $fixtureDir('pestconfig-matrix'); + $instance = new PestFileDiscoverer([$dir], dirname(__DIR__, 2), []); + + expect($instance->discoverPestFiles())->toBeEmpty(); +}); + +test('default discovery still searches the project root', function () use ($fixtureDir): void { + $dir = $fixtureDir('pesthook-scope'); + $outside = $fixtureDir('pestconfig-matrix'); + $instance = new PestFileDiscoverer([$dir], dirname($dir)); + + expect($instance->isPestConfigFile($outside.'/Pest.php'))->toBeTrue(); +}); + +test('PHPStan passes explicit configuration paths to the discovery service', function () use ($fixtureDir): void { + $dir = $fixtureDir('pesthook-scope'); + $outside = $fixtureDir('pestconfig-matrix'); + $project = dirname(__DIR__, 2); + $container = new ContainerFactory($project)->create(sys_get_temp_dir().'/pest-discovery-container', [ + $project.'/extension.neon', + __DIR__.'/Fixtures/pest-discovery.neon', + ], [$outside]); + $instance = $container->getByType(PestFileDiscoverer::class); + + expect($instance->isPestConfigFile($dir.'/Pest.php'))->toBeTrue() + ->and($instance->isPestConfigFile($outside.'/Pest.php'))->toBeFalse() + ->and($instance->isPestConfigFile(__DIR__.'/../Pest.php'))->toBeTrue(); +}); From c9956bc091bc0ebfd3d000efbc12af531ce21804 Mon Sep 17 00:00:00 2001 From: Carlo Gadducci <50781338+wanted80@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:10:02 +0200 Subject: [PATCH 2/3] test: isolate discovery container temporary directory --- tests/Type/PestFileDiscovererTest.php | 41 +++++++++++++++++++++------ 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/tests/Type/PestFileDiscovererTest.php b/tests/Type/PestFileDiscovererTest.php index bd3c550..488800d 100644 --- a/tests/Type/PestFileDiscovererTest.php +++ b/tests/Type/PestFileDiscovererTest.php @@ -4,8 +4,11 @@ namespace Tests\Type; +use FilesystemIterator; use Pest\PHPStan\Type\Pest\PestFileDiscoverer; use PHPStan\DependencyInjection\ContainerFactory; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; use RuntimeException; $fixtureDir = static function (string $name): string { @@ -112,13 +115,33 @@ $dir = $fixtureDir('pesthook-scope'); $outside = $fixtureDir('pestconfig-matrix'); $project = dirname(__DIR__, 2); - $container = new ContainerFactory($project)->create(sys_get_temp_dir().'/pest-discovery-container', [ - $project.'/extension.neon', - __DIR__.'/Fixtures/pest-discovery.neon', - ], [$outside]); - $instance = $container->getByType(PestFileDiscoverer::class); - - expect($instance->isPestConfigFile($dir.'/Pest.php'))->toBeTrue() - ->and($instance->isPestConfigFile($outside.'/Pest.php'))->toBeFalse() - ->and($instance->isPestConfigFile(__DIR__.'/../Pest.php'))->toBeTrue(); + $temporaryDir = sys_get_temp_dir().'/pest-discovery-container-'.bin2hex(random_bytes(16)); + mkdir($temporaryDir, 0700); + + try { + $container = new ContainerFactory($project)->create($temporaryDir, [ + $project.'/extension.neon', + __DIR__.'/Fixtures/pest-discovery.neon', + ], [$outside]); + $instance = $container->getByType(PestFileDiscoverer::class); + + expect($instance->isPestConfigFile($dir.'/Pest.php'))->toBeTrue() + ->and($instance->isPestConfigFile($outside.'/Pest.php'))->toBeFalse() + ->and($instance->isPestConfigFile(__DIR__.'/../Pest.php'))->toBeTrue(); + } finally { + $files = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($temporaryDir, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($files as $file) { + if ($file->isDir()) { + rmdir($file->getPathname()); + } else { + unlink($file->getPathname()); + } + } + + rmdir($temporaryDir); + } }); From 32ae6bb353cf1a922cd4ca5a745258c0c5b733f4 Mon Sep 17 00:00:00 2001 From: Carlo Gadducci <50781338+wanted80@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:41:23 +0200 Subject: [PATCH 3/3] fix: satisfy updated lint rules and remove test notices Use null coalescing when resolving import aliases so Rector 2.7.0 and PHPStan both accept the discovery code. Replace Scope mocks without expectations with stubs to eliminate PHPUnit notices. Validated the full Composer test suite with fresh dependencies: 525 tests and 646 assertions. --- src/Type/Pest/PestFileDiscoverer.php | 4 ++-- tests/Analysis/ExpectationChainStateResolverTest.php | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Type/Pest/PestFileDiscoverer.php b/src/Type/Pest/PestFileDiscoverer.php index 7383ca7..e7e1c80 100644 --- a/src/Type/Pest/PestFileDiscoverer.php +++ b/src/Type/Pest/PestFileDiscoverer.php @@ -198,7 +198,7 @@ private function extractUseMap(array $stmts): array foreach ($stmts as $stmt) { if ($stmt instanceof Use_) { foreach ($stmt->uses as $use) { - $alias = $use->alias !== null ? $use->alias->name : $use->name->getLast(); + $alias = $use->alias->name ?? $use->name->getLast(); $useMap[$alias] = $use->name->toString(); } } @@ -207,7 +207,7 @@ private function extractUseMap(array $stmts): array foreach ($stmt->stmts as $namespacedStmt) { if ($namespacedStmt instanceof Use_) { foreach ($namespacedStmt->uses as $use) { - $alias = $use->alias !== null ? $use->alias->name : $use->name->getLast(); + $alias = $use->alias->name ?? $use->name->getLast(); $useMap[$alias] = $use->name->toString(); } } diff --git a/tests/Analysis/ExpectationChainStateResolverTest.php b/tests/Analysis/ExpectationChainStateResolverTest.php index 82f3413..9141a46 100644 --- a/tests/Analysis/ExpectationChainStateResolverTest.php +++ b/tests/Analysis/ExpectationChainStateResolverTest.php @@ -23,8 +23,8 @@ $resolver = new ExpectationChainStateResolver; $intCall = expectationEachCall(); $stringCall = expectationEachCall(); - $intState = $resolver->resolve($intCall, scopeReturningExpectationOf(new IntegerType, fn (string $class): Scope => $this->createMock($class))); - $stringState = $resolver->resolve($stringCall, scopeReturningExpectationOf(new StringType, fn (string $class): Scope => $this->createMock($class))); + $intState = $resolver->resolve($intCall, scopeReturningExpectationOf(new IntegerType, fn (string $class): Scope => $this->createStub($class))); + $stringState = $resolver->resolve($stringCall, scopeReturningExpectationOf(new StringType, fn (string $class): Scope => $this->createStub($class))); expect($intState)->not->toBeNull() ->and($stringState)->not->toBeNull() ->and($intState->originalValueType->describe(VerbosityLevel::typeOnly()))->toBe('int') @@ -36,7 +36,7 @@ $cache = cacheOf($resolver); expect($cache)->toBeEmpty(); $call = expectationEachCall(); - $resolver->resolve($call, scopeReturningExpectationOf(new IntegerType, fn (string $class): Scope => $this->createMock($class))); + $resolver->resolve($call, scopeReturningExpectationOf(new IntegerType, fn (string $class): Scope => $this->createStub($class))); expect($cache)->toHaveCount(1, 'The resolved node should be memoised.'); unset($call); expect($cache)->toHaveCount( @@ -50,10 +50,10 @@ function expectationEachCall(): MethodCall return new MethodCall(new Variable('expectation'), new Identifier('each')); } -function scopeReturningExpectationOf(Type $valueType, callable $mockFactory): Scope +function scopeReturningExpectationOf(Type $valueType, callable $stubFactory): Scope { $expectationType = new GenericObjectType(Expectation::class, [$valueType]); - $scope = $mockFactory(Scope::class); + $scope = $stubFactory(Scope::class); $scope->method('getType')->willReturn($expectationType); return $scope;