From 10c222a1624df1eeac6fab1ac01ad22ed71c5d07 Mon Sep 17 00:00:00 2001
From: Markus Staab <markus.staab@redaxo.de>
Date: Mon, 12 May 2025 14:52:04 +0200
Subject: [PATCH] Fix inconsistent analysis on case-insensitive filesystems

Update e2e-tests.yml

Update e2e-tests.yml

Update e2e-tests.yml

Update e2e-tests.yml

Update e2e-tests.yml

Update e2e-tests.yml

Update e2e-tests.yml

fix PathRoutingParser

extract and re-use AnalysedFilesResolver

use AnalysedFilesResolver more

use AnalysedFilesResolver more

simplify

test also on windows

added failling ubuntu test

added feature detect

simplify detect

more assertions

simplify

Update AnalysedFilesResolver.php
---
 .github/workflows/e2e-tests.yml               | 45 ++++++++++++++++++-
 e2e/bug-12972/autoloader.php                  |  7 +++
 e2e/bug-12972/phpstan.dist.neon               |  8 ++++
 e2e/bug-12972/src/folder/file2.php            |  7 +++
 e2e/bug-12972/src/other/file.php              | 10 +++++
 src/Analyser/AnalysedFilesResolver.php        | 44 ++++++++++++++++++
 src/Analyser/Analyser.php                     |  5 +--
 src/Analyser/FileAnalyser.php                 | 21 ++++-----
 .../Ignore/IgnoredErrorHelperResult.php       | 11 ++---
 src/Analyser/NodeScopeResolver.php            | 10 ++---
 src/Command/AnalyseApplication.php            |  3 +-
 src/Command/FixerWorkerCommand.php            |  5 ++-
 src/Command/WorkerCommand.php                 | 17 +++----
 src/Dependency/NodeDependencies.php           |  6 +--
 src/File/FilesystemHelper.php                 | 21 +++++++++
 src/Parser/PathRoutingParser.php              | 12 ++---
 src/PhpDoc/StubValidator.php                  |  6 +--
 tests/PHPStan/Analyser/AnalyserTest.php       |  2 +-
 18 files changed, 186 insertions(+), 54 deletions(-)
 create mode 100644 e2e/bug-12972/autoloader.php
 create mode 100644 e2e/bug-12972/phpstan.dist.neon
 create mode 100644 e2e/bug-12972/src/folder/file2.php
 create mode 100644 e2e/bug-12972/src/other/file.php
 create mode 100644 src/Analyser/AnalysedFilesResolver.php
 create mode 100644 src/File/FilesystemHelper.php

diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml
index 41d2923444..e40c3bac26 100644
--- a/.github/workflows/e2e-tests.yml
+++ b/.github/workflows/e2e-tests.yml
@@ -308,7 +308,7 @@ jobs:
 
   e2e-tests:
     name: "E2E tests"
-    runs-on: "ubuntu-latest"
+    runs-on: ${{ matrix.os }}
     timeout-minutes: 60
 
     strategy:
@@ -317,84 +317,127 @@ jobs:
           - script: "bin/phpstan analyse -l 8 -a tests/e2e/data/timecop.php -c tests/e2e/data/empty.neon tests/e2e/data/timecop.php"
             tools: "pecl"
             extensions: "timecop-beta"
+            os: "ubuntu-latest"
           - script: "bin/phpstan analyse -l 8 -a tests/e2e/data/soap.php -c tests/e2e/data/empty.neon tests/e2e/data/soap.php"
             extensions: "soap"
+            os: "ubuntu-latest"
           - script: "bin/phpstan analyse -l 8 -a tests/e2e/data/soap.php -c tests/e2e/data/empty.neon tests/e2e/data/soap.php"
             extensions: ""
+            os: "ubuntu-latest"
           - script: "bin/phpstan analyse -l 8 tests/e2e/anon-class/Granularity.php"
             extensions: ""
+            os: "ubuntu-latest"
           - script: "bin/phpstan analyse -l 8 e2e/phpstan-phpunit-190/test.php -c e2e/phpstan-phpunit-190/test.neon"
             extensions: ""
+            os: "ubuntu-latest"
           - script: "bin/phpstan analyse e2e/only-files-not-analysed-trait/src -c e2e/only-files-not-analysed-trait/ignore.neon"
             extensions: ""
+            os: "ubuntu-latest"
           - script: "bin/phpstan analyse e2e/only-files-not-analysed-trait/src/Foo.php e2e/only-files-not-analysed-trait/src/BarTrait.php -c e2e/only-files-not-analysed-trait/no-ignore.neon"
             extensions: ""
+            os: "ubuntu-latest"
           - script: |
               cd e2e/baseline-uninit-prop-trait
               ../../bin/phpstan analyse --debug --configuration test-no-baseline.neon --generate-baseline test-baseline.neon
               ../../bin/phpstan analyse --debug --configuration test.neon
+            os: "ubuntu-latest"
           - script: |
               cd e2e/baseline-uninit-prop-trait
               ../../bin/phpstan analyse --configuration test-no-baseline.neon --generate-baseline test-baseline.neon
               ../../bin/phpstan analyse --configuration test.neon
+            os: "ubuntu-latest"
           - script: |
               cd e2e/discussion-11362
               composer install
               ../../bin/phpstan
+            os: "ubuntu-latest"
           - script: |
               cd e2e/bug-11819
               ../../bin/phpstan
+            os: "ubuntu-latest"
           - script: |
               cd e2e/composer-and-phpstan-version-config
               composer install --ignore-platform-reqs
               ../../bin/phpstan analyze test.php --level=0
+            os: "ubuntu-latest"
           - script: |
               cd e2e/composer-max-version
               composer install
               ../../bin/phpstan analyze test.php --level=0
+            os: "ubuntu-latest"
           - script: |
               cd e2e/composer-min-max-version
               composer install
               ../../bin/phpstan analyze test.php --level=0
+            os: "ubuntu-latest"
           - script: |
               cd e2e/composer-min-open-end-version
               composer install
               ../../bin/phpstan analyze test.php --level=0
+            os: "ubuntu-latest"
           - script: |
               cd e2e/composer-min-version-v5
               composer install --ignore-platform-reqs
               ../../bin/phpstan analyze test.php --level=0
+            os: "ubuntu-latest"
           - script: |
               cd e2e/composer-min-version-v7
               composer install --ignore-platform-reqs
               ../../bin/phpstan analyze test.php --level=0
+            os: "ubuntu-latest"
           - script: |
               cd e2e/composer-min-version
               composer install
               ../../bin/phpstan analyze test.php --level=0
+            os: "ubuntu-latest"
           - script: |
               cd e2e/composer-no-versions
               composer install
               ../../bin/phpstan analyze test.php --level=0
+            os: "ubuntu-latest"
           - script: |
               cd e2e/composer-version-config-invalid
               OUTPUT=$(../bashunit -a exit_code "1" ../../bin/phpstan)
               echo "$OUTPUT"
               ../bashunit -a contains 'Invalid configuration' "$OUTPUT"
               ../bashunit -a contains 'Invalid PHP version range: phpVersion.max should be greater or equal to phpVersion.min.' "$OUTPUT"
+            os: "ubuntu-latest"
           - script: |
               cd e2e/composer-version-config-patch
               composer install --ignore-platform-reqs
               ../../bin/phpstan analyze test.php --level=0
+            os: "ubuntu-latest"
           - script: |
               cd e2e/composer-version-config
               composer install
               ../../bin/phpstan analyze test.php --level=0
+            os: "ubuntu-latest"
+          - script: |
+              cd e2e/bug-12972
+              OUTPUT=$(../bashunit -a exit_code "1" ../../bin/phpstan)
+              echo "$OUTPUT"
+              ../bashunit -a contains 'Internal error: Failed opening required' "$OUTPUT"
+              ../bashunit -a contains 'e2e/bug-12972/src/OTHER/file.php' "$OUTPUT"
+            os: "ubuntu-latest"
+          - script: |
+              cd e2e/bug-12972
+              ../../bin/phpstan analyze
+            os: "macos-latest"
+          - script: |
+              cd e2e/bug-12972
+              ../../bin/phpstan analyze
+            os: "windows-latest"
 
     steps:
       - name: "Checkout"
         uses: actions/checkout@v4
 
+      - name: "Install GNU Patch on macOS" # see https://github.com/cweagans/composer-patches/issues/326
+        if: runner.os == 'macOS'
+        run: |
+          brew install gpatch
+          echo "/opt/homebrew/opt/gpatch/libexec/gnubin" >> $GITHUB_PATH
+
       - name: "Install PHP"
         uses: "shivammathur/setup-php@v2"
         with:
diff --git a/e2e/bug-12972/autoloader.php b/e2e/bug-12972/autoloader.php
new file mode 100644
index 0000000000..ea5cc8a051
--- /dev/null
+++ b/e2e/bug-12972/autoloader.php
@@ -0,0 +1,7 @@
+<?php
+
+spl_autoload_register(function($class) {
+	if ($class === \other12972\MyClass::class) {
+		require_once __DIR__ . '/src/OTHER/file.php'; // wrong case sensitivity on purpose
+	}
+});
diff --git a/e2e/bug-12972/phpstan.dist.neon b/e2e/bug-12972/phpstan.dist.neon
new file mode 100644
index 0000000000..2aea4ef34b
--- /dev/null
+++ b/e2e/bug-12972/phpstan.dist.neon
@@ -0,0 +1,8 @@
+parameters:
+    level: 9
+
+    paths:
+        - src
+
+    bootstrapFiles:
+        - autoloader.php
diff --git a/e2e/bug-12972/src/folder/file2.php b/e2e/bug-12972/src/folder/file2.php
new file mode 100644
index 0000000000..3ae8ff79e5
--- /dev/null
+++ b/e2e/bug-12972/src/folder/file2.php
@@ -0,0 +1,7 @@
+<?php
+
+namespace Foo12972;
+
+use other12972\MyClass;
+
+function doBar(MyClass $myClass):void {}
diff --git a/e2e/bug-12972/src/other/file.php b/e2e/bug-12972/src/other/file.php
new file mode 100644
index 0000000000..6e280025d1
--- /dev/null
+++ b/e2e/bug-12972/src/other/file.php
@@ -0,0 +1,10 @@
+<?php
+
+namespace other12972;
+
+class MyClass {
+	public function doSomething(): int
+	{
+		return 1;
+	}
+}
diff --git a/src/Analyser/AnalysedFilesResolver.php b/src/Analyser/AnalysedFilesResolver.php
new file mode 100644
index 0000000000..bf466a1bce
--- /dev/null
+++ b/src/Analyser/AnalysedFilesResolver.php
@@ -0,0 +1,44 @@
+<?php declare(strict_types = 1);
+
+namespace PHPStan\Analyser;
+
+use PHPStan\File\FilesystemHelper;
+use function array_fill_keys;
+use function array_map;
+use function strtolower;
+
+final class AnalysedFilesResolver
+{
+
+	/** @var bool[] filePath(string) => bool(true) */
+	private array $analysedFiles;
+
+	/**
+	 * @param string[] $files
+	 */
+	public function __construct(array $files = [])
+	{
+		$this->setAnalysedFiles($files);
+	}
+
+	/**
+	 * @param string[] $files
+	 */
+	public function setAnalysedFiles(array $files): void
+	{
+		if (FilesystemHelper::isCaseSensitive() === false) {
+			$files = array_map(static fn (string $file): string => strtolower($file), $files);
+		}
+		$this->analysedFiles = array_fill_keys($files, true);
+	}
+
+	public function isInAnalyzedFiles(string $file): bool
+	{
+		if (FilesystemHelper::isCaseSensitive() === false) {
+			$file = strtolower($file);
+		}
+
+		return isset($this->analysedFiles[$file]);
+	}
+
+}
diff --git a/src/Analyser/Analyser.php b/src/Analyser/Analyser.php
index 8eea5bacec..1960810ebc 100644
--- a/src/Analyser/Analyser.php
+++ b/src/Analyser/Analyser.php
@@ -7,7 +7,6 @@
 use PHPStan\Collectors\Registry as CollectorRegistry;
 use PHPStan\Rules\Registry as RuleRegistry;
 use Throwable;
-use function array_fill_keys;
 use function array_merge;
 use function count;
 use function memory_get_peak_usage;
@@ -47,7 +46,7 @@ public function analyse(
 		}
 
 		$this->nodeScopeResolver->setAnalysedFiles($allAnalysedFiles);
-		$allAnalysedFiles = array_fill_keys($allAnalysedFiles, true);
+		$analyzedFilesResolver = new AnalysedFilesResolver($allAnalysedFiles);
 
 		/** @var list<Error> $errors */
 		$errors = [];
@@ -78,7 +77,7 @@ public function analyse(
 			try {
 				$fileAnalyserResult = $this->fileAnalyser->analyseFile(
 					$file,
-					$allAnalysedFiles,
+					$analyzedFilesResolver,
 					$this->ruleRegistry,
 					$this->collectorRegistry,
 					null,
diff --git a/src/Analyser/FileAnalyser.php b/src/Analyser/FileAnalyser.php
index 9e16ae4360..1ecc42be72 100644
--- a/src/Analyser/FileAnalyser.php
+++ b/src/Analyser/FileAnalyser.php
@@ -63,12 +63,11 @@ public function __construct(
 	}
 
 	/**
-	 * @param array<string, true> $analysedFiles
 	 * @param callable(Node $node, Scope $scope): void|null $outerNodeCallback
 	 */
 	public function analyseFile(
 		string $file,
-		array $analysedFiles,
+		AnalysedFilesResolver $analysedFilesResolver,
 		RuleRegistry $ruleRegistry,
 		CollectorRegistry $collectorRegistry,
 		?callable $outerNodeCallback,
@@ -88,13 +87,14 @@ public function analyseFile(
 		$exportedNodes = [];
 		$linesToIgnore = [];
 		$unmatchedLineIgnores = [];
+
 		if (is_file($file)) {
 			try {
-				$this->collectErrors($analysedFiles);
+				$this->collectErrors($analysedFilesResolver);
 				$parserNodes = $this->parser->parseFile($file);
 				$linesToIgnore = $unmatchedLineIgnores = [$file => $this->getLinesToIgnoreFromTokens($parserNodes)];
 				$temporaryFileErrors = [];
-				$nodeCallback = function (Node $node, Scope $scope) use (&$fileErrors, &$fileCollectedData, &$fileDependencies, &$usedTraitFileDependencies, &$exportedNodes, $file, $ruleRegistry, $collectorRegistry, $outerNodeCallback, $analysedFiles, &$linesToIgnore, &$unmatchedLineIgnores, &$temporaryFileErrors): void {
+				$nodeCallback = function (Node $node, Scope $scope) use (&$fileErrors, &$fileCollectedData, &$fileDependencies, &$usedTraitFileDependencies, &$exportedNodes, $file, $ruleRegistry, $collectorRegistry, $outerNodeCallback, $analysedFilesResolver, &$linesToIgnore, &$unmatchedLineIgnores, &$temporaryFileErrors): void {
 					if ($node instanceof Node\Stmt\Trait_) {
 						foreach (array_keys($linesToIgnore[$file] ?? []) as $lineToIgnore) {
 							if ($lineToIgnore < $node->getStartLine() || $lineToIgnore > $node->getEndLine()) {
@@ -205,7 +205,7 @@ public function analyseFile(
 
 					try {
 						$dependencies = $this->dependencyResolver->resolveDependencies($node, $scope);
-						foreach ($dependencies->getFileDependencies($scope->getFile(), $analysedFiles) as $dependentFile) {
+						foreach ($dependencies->getFileDependencies($scope->getFile(), $analysedFilesResolver) as $dependentFile) {
 							$fileDependencies[] = $dependentFile;
 						}
 						if ($dependencies->getExportedNode() !== null) {
@@ -224,7 +224,7 @@ public function analyseFile(
 					}
 
 					$usedTraitDependencies = $this->dependencyResolver->resolveUsedTraitDependencies($node);
-					foreach ($usedTraitDependencies->getFileDependencies($scope->getFile(), $analysedFiles) as $dependentFile) {
+					foreach ($usedTraitDependencies->getFileDependencies($scope->getFile(), $analysedFilesResolver) as $dependentFile) {
 						$usedTraitFileDependencies[] = $dependentFile;
 					}
 				};
@@ -330,14 +330,11 @@ private function getLinesToIgnoreFromTokens(array $nodes): array
 		return $nodes[0]->getAttribute('linesToIgnore', []);
 	}
 
-	/**
-	 * @param array<string, true> $analysedFiles
-	 */
-	private function collectErrors(array $analysedFiles): void
+	private function collectErrors(AnalysedFilesResolver $analyzedFilesResolver): void
 	{
 		$this->filteredPhpErrors = [];
 		$this->allPhpErrors = [];
-		set_error_handler(function (int $errno, string $errstr, string $errfile, int $errline) use ($analysedFiles): bool {
+		set_error_handler(function (int $errno, string $errstr, string $errfile, int $errline) use ($analyzedFilesResolver): bool {
 			if ((error_reporting() & $errno) === 0) {
 				// silence @ operator
 				return true;
@@ -351,7 +348,7 @@ private function collectErrors(array $analysedFiles): void
 				return true;
 			}
 
-			if (!isset($analysedFiles[$errfile])) {
+			if (!$analyzedFilesResolver->isInAnalyzedFiles($errfile)) {
 				return true;
 			}
 
diff --git a/src/Analyser/Ignore/IgnoredErrorHelperResult.php b/src/Analyser/Ignore/IgnoredErrorHelperResult.php
index 529e1ae4a4..ed3865922e 100644
--- a/src/Analyser/Ignore/IgnoredErrorHelperResult.php
+++ b/src/Analyser/Ignore/IgnoredErrorHelperResult.php
@@ -2,11 +2,10 @@
 
 namespace PHPStan\Analyser\Ignore;
 
+use PHPStan\Analyser\AnalysedFilesResolver;
 use PHPStan\Analyser\Error;
 use PHPStan\File\FileHelper;
 use PHPStan\ShouldNotHappenException;
-use function array_fill_keys;
-use function array_key_exists;
 use function array_values;
 use function count;
 use function is_array;
@@ -43,12 +42,11 @@ public function getErrors(): array
 
 	/**
 	 * @param Error[] $errors
-	 * @param string[] $analysedFiles
 	 */
 	public function process(
 		array $errors,
 		bool $onlyFiles,
-		array $analysedFiles,
+		AnalysedFilesResolver $analysedFilesResolver,
 		bool $hasInternalErrors,
 	): IgnoredErrorHelperProcessedResult
 	{
@@ -190,8 +188,6 @@ public function process(
 			), $unmatchedIgnoredError['file'], $unmatchedIgnoredError['line'], false))->withIdentifier('ignore.count');
 		}
 
-		$analysedFilesKeys = array_fill_keys($analysedFiles, true);
-
 		if (!$hasInternalErrors) {
 			foreach ($unmatchedIgnoredErrors as $unmatchedIgnoredError) {
 				$reportUnmatched = $unmatchedIgnoredError['reportUnmatched'] ?? $this->reportUnmatchedIgnoredErrors;
@@ -214,7 +210,8 @@ public function process(
 						), $unmatchedIgnoredError['file'], $unmatchedIgnoredError['line'], false))->withIdentifier('ignore.count');
 					}
 				} elseif (isset($unmatchedIgnoredError['realPath'])) {
-					if (!array_key_exists($unmatchedIgnoredError['realPath'], $analysedFilesKeys)) {
+
+					if (!$analysedFilesResolver->isInAnalyzedFiles($unmatchedIgnoredError['realPath'])) {
 						continue;
 					}
 
diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php
index 8c070bb040..4a0ab41668 100644
--- a/src/Analyser/NodeScopeResolver.php
+++ b/src/Analyser/NodeScopeResolver.php
@@ -229,8 +229,7 @@ final class NodeScopeResolver
 	private const LOOP_SCOPE_ITERATIONS = 3;
 	private const GENERALIZE_AFTER_ITERATION = 1;
 
-	/** @var bool[] filePath(string) => bool(true) */
-	private array $analysedFiles = [];
+	private AnalysedFilesResolver $analysedFilesResolver;
 
 	/** @var array<string, true> */
 	private array $earlyTerminatingMethodNames;
@@ -284,6 +283,7 @@ public function __construct(
 			}
 		}
 		$this->earlyTerminatingMethodNames = $earlyTerminatingMethodNames;
+		$this->analysedFilesResolver = new AnalysedFilesResolver();
 	}
 
 	/**
@@ -292,7 +292,7 @@ public function __construct(
 	 */
 	public function setAnalysedFiles(array $files): void
 	{
-		$this->analysedFiles = array_fill_keys($files, true);
+		$this->analysedFilesResolver->setAnalysedFiles($files);
 	}
 
 	/**
@@ -6275,7 +6275,7 @@ private function processTraitUse(Node\Stmt\TraitUse $node, MutatingScope $classS
 				continue; // trait from eval or from PHP itself
 			}
 			$fileName = $this->fileHelper->normalizePath($traitFileName);
-			if (!isset($this->analysedFiles[$fileName])) {
+			if (!$this->analysedFilesResolver->isInAnalyzedFiles($fileName)) {
 				continue;
 			}
 			$adaptations = [];
@@ -6394,7 +6394,7 @@ private function processCalledMethod(MethodReflection $methodReflection): ?Mutat
 		$this->calledMethodStack[$stackName] = true;
 
 		$fileName = $this->fileHelper->normalizePath($declaringClass->getFileName());
-		if (!isset($this->analysedFiles[$fileName])) {
+		if (!$this->analysedFilesResolver->isInAnalyzedFiles($fileName)) {
 			return null;
 		}
 		$parserNodes = $this->parser->parseFile($fileName);
diff --git a/src/Command/AnalyseApplication.php b/src/Command/AnalyseApplication.php
index 7a0820cc46..cd554006d4 100644
--- a/src/Command/AnalyseApplication.php
+++ b/src/Command/AnalyseApplication.php
@@ -2,6 +2,7 @@
 
 namespace PHPStan\Command;
 
+use PHPStan\Analyser\AnalysedFilesResolver;
 use PHPStan\Analyser\AnalyserResult;
 use PHPStan\Analyser\AnalyserResultFinalizer;
 use PHPStan\Analyser\Ignore\IgnoredErrorHelper;
@@ -151,7 +152,7 @@ public function analyse(
 				}
 			}
 
-			$ignoredErrorHelperProcessedResult = $ignoredErrorHelperResult->process($errors, $onlyFiles, $files, $hasInternalErrors);
+			$ignoredErrorHelperProcessedResult = $ignoredErrorHelperResult->process($errors, $onlyFiles, new AnalysedFilesResolver($files), $hasInternalErrors);
 			$fileSpecificErrors = $ignoredErrorHelperProcessedResult->getNotIgnoredErrors();
 			$notFileSpecificErrors = $ignoredErrorHelperProcessedResult->getOtherIgnoreMessages();
 			$collectedData = $analyserResult->getCollectedData();
diff --git a/src/Command/FixerWorkerCommand.php b/src/Command/FixerWorkerCommand.php
index 54e1da4c41..28573d0a8b 100644
--- a/src/Command/FixerWorkerCommand.php
+++ b/src/Command/FixerWorkerCommand.php
@@ -3,6 +3,7 @@
 namespace PHPStan\Command;
 
 use Clue\React\NDJson\Encoder;
+use PHPStan\Analyser\AnalysedFilesResolver;
 use PHPStan\Analyser\AnalyserResult;
 use PHPStan\Analyser\AnalyserResultFinalizer;
 use PHPStan\Analyser\Error;
@@ -302,7 +303,7 @@ function (array $errors, array $locallyIgnoredErrors, array $analysedFiles) use
 				$ignoredErrorHelperProcessedResult = $ignoredErrorHelperResult->process(
 					$finalizerResult->getErrors(),
 					$isOnlyFiles,
-					$inceptionFiles,
+					new AnalysedFilesResolver($inceptionFiles),
 					$hasInternalErrors,
 				);
 				$ignoreFileErrors = [];
@@ -357,7 +358,7 @@ private function transformErrorIntoInternalError(Error $error): InternalError
 	 */
 	private function filterErrors(array $errors, IgnoredErrorHelperResult $ignoredErrorHelperResult, bool $onlyFiles, array $inceptionFiles, bool $hasInternalErrors): array
 	{
-		$ignoredErrorHelperProcessedResult = $ignoredErrorHelperResult->process($errors, $onlyFiles, $inceptionFiles, $hasInternalErrors);
+		$ignoredErrorHelperProcessedResult = $ignoredErrorHelperResult->process($errors, $onlyFiles, new AnalysedFilesResolver($inceptionFiles), $hasInternalErrors);
 		$finalErrors = [];
 		foreach ($ignoredErrorHelperProcessedResult->getNotIgnoredErrors() as $error) {
 			if ($error->getIdentifier() === null) {
diff --git a/src/Command/WorkerCommand.php b/src/Command/WorkerCommand.php
index 0fa30e1755..e812003a26 100644
--- a/src/Command/WorkerCommand.php
+++ b/src/Command/WorkerCommand.php
@@ -4,6 +4,7 @@
 
 use Clue\React\NDJson\Decoder;
 use Clue\React\NDJson\Encoder;
+use PHPStan\Analyser\AnalysedFilesResolver;
 use PHPStan\Analyser\FileAnalyser;
 use PHPStan\Analyser\InternalError;
 use PHPStan\Analyser\NodeScopeResolver;
@@ -23,7 +24,6 @@
 use Symfony\Component\Console\Input\InputOption;
 use Symfony\Component\Console\Output\OutputInterface;
 use Throwable;
-use function array_fill_keys;
 use function array_filter;
 use function array_merge;
 use function array_unshift;
@@ -136,17 +136,17 @@ protected function execute(InputInterface $input, OutputInterface $output): int
 		$nodeScopeResolver = $container->getByType(NodeScopeResolver::class);
 		$nodeScopeResolver->setAnalysedFiles($analysedFiles);
 
-		$analysedFiles = array_fill_keys($analysedFiles, true);
+		$analyzedFilesResolver = new AnalysedFilesResolver($analysedFiles);
 
 		$tcpConnector = new TcpConnector($loop);
-		$tcpConnector->connect(sprintf('127.0.0.1:%d', $port))->then(function (ConnectionInterface $connection) use ($container, $identifier, $output, $analysedFiles, $tmpFile, $insteadOfFile): void {
+		$tcpConnector->connect(sprintf('127.0.0.1:%d', $port))->then(function (ConnectionInterface $connection) use ($container, $identifier, $output, $analyzedFilesResolver, $tmpFile, $insteadOfFile): void {
 			// phpcs:disable SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly
 			$jsonInvalidUtf8Ignore = defined('JSON_INVALID_UTF8_IGNORE') ? JSON_INVALID_UTF8_IGNORE : 0;
 			// phpcs:enable
 			$out = new Encoder($connection, $jsonInvalidUtf8Ignore);
 			$in = new Decoder($connection, true, 512, $jsonInvalidUtf8Ignore, $container->getParameter('parallel')['buffer']);
 			$out->write(['action' => 'hello', 'identifier' => $identifier]);
-			$this->runWorker($container, $out, $in, $output, $analysedFiles, $tmpFile, $insteadOfFile);
+			$this->runWorker($container, $out, $in, $output, $analyzedFilesResolver, $tmpFile, $insteadOfFile);
 		});
 
 		$loop->run();
@@ -158,15 +158,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int
 		return 0;
 	}
 
-	/**
-	 * @param array<string, true> $analysedFiles
-	 */
 	private function runWorker(
 		Container $container,
 		WritableStreamInterface $out,
 		ReadableStreamInterface $in,
 		OutputInterface $output,
-		array $analysedFiles,
+		AnalysedFilesResolver $analysedFilesResolver,
 		?string $tmpFile,
 		?string $insteadOfFile,
 	): void
@@ -206,7 +203,7 @@ private function runWorker(
 		$fileAnalyser = $container->getByType(FileAnalyser::class);
 		$ruleRegistry = $container->getByType(RuleRegistry::class);
 		$collectorRegistry = $container->getByType(CollectorRegistry::class);
-		$in->on('data', static function (array $json) use ($fileAnalyser, $ruleRegistry, $collectorRegistry, $out, $analysedFiles, $tmpFile, $insteadOfFile): void {
+		$in->on('data', static function (array $json) use ($fileAnalyser, $ruleRegistry, $collectorRegistry, $out, $analysedFilesResolver, $tmpFile, $insteadOfFile): void {
 			$action = $json['action'];
 			if ($action !== 'analyse') {
 				return;
@@ -230,7 +227,7 @@ private function runWorker(
 					if ($file === $insteadOfFile) {
 						$file = $tmpFile;
 					}
-					$fileAnalyserResult = $fileAnalyser->analyseFile($file, $analysedFiles, $ruleRegistry, $collectorRegistry, null);
+					$fileAnalyserResult = $fileAnalyser->analyseFile($file, $analysedFilesResolver, $ruleRegistry, $collectorRegistry, null);
 					$fileErrors = $fileAnalyserResult->getErrors();
 					$filteredPhpErrors = array_merge($filteredPhpErrors, $fileAnalyserResult->getFilteredPhpErrors());
 					$allPhpErrors = array_merge($allPhpErrors, $fileAnalyserResult->getAllPhpErrors());
diff --git a/src/Dependency/NodeDependencies.php b/src/Dependency/NodeDependencies.php
index ac30175b35..05353f2a66 100644
--- a/src/Dependency/NodeDependencies.php
+++ b/src/Dependency/NodeDependencies.php
@@ -2,6 +2,7 @@
 
 namespace PHPStan\Dependency;
 
+use PHPStan\Analyser\AnalysedFilesResolver;
 use PHPStan\File\FileHelper;
 use PHPStan\Reflection\ClassReflection;
 use PHPStan\Reflection\FunctionReflection;
@@ -22,10 +23,9 @@ public function __construct(
 	}
 
 	/**
-	 * @param array<string, true> $analysedFiles
 	 * @return string[]
 	 */
-	public function getFileDependencies(string $currentFile, array $analysedFiles): array
+	public function getFileDependencies(string $currentFile, AnalysedFilesResolver $analyzedFilesResolver): array
 	{
 		$dependencies = [];
 
@@ -40,7 +40,7 @@ public function getFileDependencies(string $currentFile, array $analysedFiles):
 				continue;
 			}
 
-			if (!isset($analysedFiles[$dependencyFile])) {
+			if (!$analyzedFilesResolver->isInAnalyzedFiles($dependencyFile)) {
 				continue;
 			}
 
diff --git a/src/File/FilesystemHelper.php b/src/File/FilesystemHelper.php
new file mode 100644
index 0000000000..c8e27b668d
--- /dev/null
+++ b/src/File/FilesystemHelper.php
@@ -0,0 +1,21 @@
+<?php declare(strict_types = 1);
+
+namespace PHPStan\File;
+
+use function is_file;
+use function strtoupper;
+
+final class FilesystemHelper
+{
+
+	private static ?bool $isCaseSensitive = null;
+
+	public static function isCaseSensitive(): bool
+	{
+		if (self::$isCaseSensitive === null) {
+			self::$isCaseSensitive = is_file(__DIR__ . '/' . strtoupper(__FILE__));
+		}
+		return self::$isCaseSensitive;
+	}
+
+}
diff --git a/src/Parser/PathRoutingParser.php b/src/Parser/PathRoutingParser.php
index 0eb55ab07d..c677bf3b2a 100644
--- a/src/Parser/PathRoutingParser.php
+++ b/src/Parser/PathRoutingParser.php
@@ -2,8 +2,8 @@
 
 namespace PHPStan\Parser;
 
+use PHPStan\Analyser\AnalysedFilesResolver;
 use PHPStan\File\FileHelper;
-use function array_fill_keys;
 use function array_slice;
 use function count;
 use function explode;
@@ -18,8 +18,7 @@ final class PathRoutingParser implements Parser
 
 	private ?string $singleReflectionFile;
 
-	/** @var bool[] filePath(string) => bool(true) */
-	private array $analysedFiles = [];
+	private AnalysedFilesResolver $analysedFilesResolver;
 
 	public function __construct(
 		private FileHelper $fileHelper,
@@ -30,6 +29,7 @@ public function __construct(
 	)
 	{
 		$this->singleReflectionFile = $singleReflectionFile !== null ? $fileHelper->normalizePath($singleReflectionFile) : null;
+		$this->analysedFilesResolver = new AnalysedFilesResolver();
 	}
 
 	/**
@@ -37,7 +37,7 @@ public function __construct(
 	 */
 	public function setAnalysedFiles(array $files): void
 	{
-		$this->analysedFiles = array_fill_keys($files, true);
+		$this->analysedFilesResolver->setAnalysedFiles($files);
 	}
 
 	public function parseFile(string $file): array
@@ -51,7 +51,7 @@ public function parseFile(string $file): array
 		}
 
 		$file = $this->fileHelper->normalizePath($file);
-		if (!isset($this->analysedFiles[$file]) && $file !== $this->singleReflectionFile) {
+		if (!$this->analysedFilesResolver->isInAnalyzedFiles($file) && $file !== $this->singleReflectionFile) {
 			// check symlinked file that still might be in analysedFiles
 			$pathParts = explode(DIRECTORY_SEPARATOR, $file);
 			for ($i = count($pathParts); $i > 1; $i--) {
@@ -63,7 +63,7 @@ public function parseFile(string $file): array
 				$realFilePath = realpath($file);
 				if ($realFilePath !== false) {
 					$normalizedRealFilePath = $this->fileHelper->normalizePath($realFilePath);
-					if (isset($this->analysedFiles[$normalizedRealFilePath])) {
+					if ($this->analysedFilesResolver->isInAnalyzedFiles($normalizedRealFilePath)) {
 						return $this->currentPhpVersionRichParser->parseFile($file);
 					}
 				}
diff --git a/src/PhpDoc/StubValidator.php b/src/PhpDoc/StubValidator.php
index b0737cbcc9..dade6a296b 100644
--- a/src/PhpDoc/StubValidator.php
+++ b/src/PhpDoc/StubValidator.php
@@ -2,6 +2,7 @@
 
 namespace PHPStan\PhpDoc;
 
+use PHPStan\Analyser\AnalysedFilesResolver;
 use PHPStan\Analyser\Error;
 use PHPStan\Analyser\FileAnalyser;
 use PHPStan\Analyser\InternalError;
@@ -97,7 +98,6 @@
 use PHPStan\Type\FileTypeMapper;
 use PHPStan\Type\ObjectType;
 use Throwable;
-use function array_fill_keys;
 use function count;
 use function sprintf;
 
@@ -137,14 +137,14 @@ public function validate(array $stubFiles, bool $debug): array
 		$pathRoutingParser = $container->getService('pathRoutingParser');
 		$pathRoutingParser->setAnalysedFiles($stubFiles);
 
-		$analysedFiles = array_fill_keys($stubFiles, true);
+		$analyzedFilesResolver = new AnalysedFilesResolver($stubFiles);
 
 		$errors = [];
 		foreach ($stubFiles as $stubFile) {
 			try {
 				$tmpErrors = $fileAnalyser->analyseFile(
 					$stubFile,
-					$analysedFiles,
+					$analyzedFilesResolver,
 					$ruleRegistry,
 					$collectorRegistry,
 					static function (): void {
diff --git a/tests/PHPStan/Analyser/AnalyserTest.php b/tests/PHPStan/Analyser/AnalyserTest.php
index 9877e01b77..c04eb111a8 100644
--- a/tests/PHPStan/Analyser/AnalyserTest.php
+++ b/tests/PHPStan/Analyser/AnalyserTest.php
@@ -680,7 +680,7 @@ private function runAnalyser(
 		);
 		$analyserResult = $finalizer->finalize($analyserResult, $onlyFiles, false)->getAnalyserResult();
 
-		$ignoredErrorHelperProcessedResult = $ignoredErrorHelperResult->process($analyserResult->getErrors(), $onlyFiles, $normalizedFilePaths, $analyserResult->hasReachedInternalErrorsCountLimit());
+		$ignoredErrorHelperProcessedResult = $ignoredErrorHelperResult->process($analyserResult->getErrors(), $onlyFiles, new AnalysedFilesResolver($normalizedFilePaths), $analyserResult->hasReachedInternalErrorsCountLimit());
 		$errors = $ignoredErrorHelperProcessedResult->getNotIgnoredErrors();
 		$errors = array_merge($errors, $ignoredErrorHelperProcessedResult->getOtherIgnoreMessages());
 		if ($analyserResult->hasReachedInternalErrorsCountLimit()) {