From d049933fc74c350497f8c92758c2a31ef89d60ad Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Sat, 18 Jul 2026 16:43:23 +0000 Subject: [PATCH 1/7] Report constant-string arguments to `#[JetBrains\PhpStorm\FileReference]` parameters that do not resolve to an existing path - Extract the include/require path resolution (current working directory + include_path + script directory) from `RequireFileExistsRule` into a new autowired `PHPStan\File\FileExistenceChecker` service, adding a `pathExists()` variant that also accepts directories. - In `FunctionCallParametersCheck`, validate every constant-string argument passed to a parameter annotated with `#[JetBrains\PhpStorm\FileReference]` and report `argument.fileReference` when the path does not resolve to an existing file or directory. This runs for every call type that funnels through the check: function, method, static-method and constructor (including promoted properties) calls. - Honor the attribute's `$basePath` constructor argument as an additional search root, resolved against the analysed file's directory when relative. - Non-constant string arguments are never flagged (no constant strings -> no check), so there are no false positives on dynamic paths. - Closures/arrow functions are not covered: their parameters use `NativeParameterReflection`, which does not carry attribute reflections. Left out of scope on purpose. --- build/collision-detector.json | 2 + src/File/FileExistenceChecker.php | 87 +++++++++++++++++ src/Rules/FunctionCallParametersCheck.php | 93 +++++++++++++++++++ src/Rules/Keywords/RequireFileExistsRule.php | 39 +------- .../Analyser/AnalyserIntegrationTest.php | 8 ++ .../data/file-reference-attribute.php | 28 ++++++ .../Rules/Classes/InstantiationRuleTest.php | 16 ++++ .../CallToFunctionParametersRuleTest.php | 20 ++++ ...equireFileExistsRuleNoConstantPathTest.php | 3 +- .../Keywords/RequireFileExistsRuleTest.php | 3 +- .../Rules/Methods/CallMethodsRuleTest.php | 15 +++ .../Methods/CallStaticMethodsRuleTest.php | 13 +++ .../file-reference-nested.php | 3 + .../Rules/data/file-reference-existing.php | 3 + tests/PHPStan/Rules/data/file-reference.php | 86 +++++++++++++++++ 15 files changed, 381 insertions(+), 38 deletions(-) create mode 100644 src/File/FileExistenceChecker.php create mode 100644 tests/PHPStan/Analyser/data/file-reference-attribute.php create mode 100644 tests/PHPStan/Rules/data/file-reference-base/file-reference-nested.php create mode 100644 tests/PHPStan/Rules/data/file-reference-existing.php create mode 100644 tests/PHPStan/Rules/data/file-reference.php diff --git a/build/collision-detector.json b/build/collision-detector.json index c5171fcc96a..a9ab4d02006 100644 --- a/build/collision-detector.json +++ b/build/collision-detector.json @@ -9,6 +9,8 @@ "../tests/PHPStan/Parser/data/cleaning-property-hooks-after.php", "../tests/PHPStan/Rules/Functions/data/duplicate-function.php", "../tests/PHPStan/Rules/Classes/data/duplicate-class.php", + "../tests/PHPStan/Rules/data/file-reference.php", + "../tests/PHPStan/Analyser/data/file-reference-attribute.php", "../tests/PHPStan/Rules/Names/data/multiple-namespaces.php", "../tests/PHPStan/Rules/Names/data/no-namespace.php", "../tests/notAutoloaded", diff --git a/src/File/FileExistenceChecker.php b/src/File/FileExistenceChecker.php new file mode 100644 index 00000000000..4c0f7f4e235 --- /dev/null +++ b/src/File/FileExistenceChecker.php @@ -0,0 +1,87 @@ + $baseDirectories + */ + public function fileExists(string $path, string $scriptDirectory, array $baseDirectories = []): bool + { + return $this->exists($path, $scriptDirectory, $baseDirectories, false); + } + + /** + * Like fileExists(), but a directory at the resolved path also counts as existing. + * + * @param list $baseDirectories + */ + public function pathExists(string $path, string $scriptDirectory, array $baseDirectories = []): bool + { + return $this->exists($path, $scriptDirectory, $baseDirectories, true); + } + + /** + * @param list $baseDirectories + */ + private function exists(string $path, string $scriptDirectory, array $baseDirectories, bool $allowDirectory): bool + { + $scriptHelper = new FileHelper($scriptDirectory); + $resolvedBaseDirectories = []; + foreach ($baseDirectories as $baseDirectory) { + // a relative base directory is resolved against the directory of the analysed script + $resolvedBaseDirectories[] = $scriptHelper->absolutizePath($baseDirectory); + } + + $directories = array_merge( + $resolvedBaseDirectories, + [$this->currentWorkingDirectory], + explode(PATH_SEPARATOR, get_include_path()), + [$scriptDirectory], + ); + + foreach ($directories as $directory) { + $absolutePath = (new FileHelper($directory))->absolutizePath($path); + + if (is_file($absolutePath)) { + return true; + } + + if ($allowDirectory && is_dir($absolutePath)) { + return true; + } + } + + return false; + } + +} diff --git a/src/Rules/FunctionCallParametersCheck.php b/src/Rules/FunctionCallParametersCheck.php index 0ba5e1a7a57..fa37ba918fe 100644 --- a/src/Rules/FunctionCallParametersCheck.php +++ b/src/Rules/FunctionCallParametersCheck.php @@ -10,6 +10,7 @@ use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\File\FileExistenceChecker; use PHPStan\Reflection\ConstantReflection; use PHPStan\Reflection\ExtendedParameterReflection; use PHPStan\Reflection\ParameterReflection; @@ -37,6 +38,7 @@ use function array_last; use function array_merge; use function count; +use function dirname; use function implode; use function in_array; use function is_int; @@ -49,6 +51,8 @@ final class FunctionCallParametersCheck { + private const FILE_REFERENCE_ATTRIBUTE = 'JetBrains\PhpStorm\FileReference'; + public function __construct( private RuleLevelHelper $ruleLevelHelper, private NullsafeCheck $nullsafeCheck, @@ -63,6 +67,7 @@ public function __construct( private bool $checkExtraArguments, #[AutowiredParameter] private bool $checkMissingTypehints, + private ?FileExistenceChecker $fileExistenceChecker = null, ) { } @@ -324,6 +329,10 @@ public function check( $errors[] = $error; } + foreach ($this->checkFileReferences($argumentsWithParameters, $scope) as $error) { + $errors[] = $error; + } + if (!$this->checkArgumentTypes && !$this->checkArgumentsPassedByReference) { return $errors; } @@ -782,6 +791,90 @@ private function processArguments( return [$errors, $newArguments]; } + /** + * Validates constant-string arguments passed to parameters annotated with + * #[JetBrains\PhpStorm\FileReference], reusing the include/require file resolution. + * + * @param array $argumentsWithParameters + * @return list + */ + private function checkFileReferences(array $argumentsWithParameters, Scope $scope): array + { + if ($this->fileExistenceChecker === null) { + return []; + } + + $errors = []; + $scriptDirectory = dirname($scope->getFile()); + foreach ($argumentsWithParameters as $i => [$argumentValue, $argumentValueType, $unpack, $argumentName, $argumentLine, $parameter]) { + if ($unpack) { + continue; + } + if (!$parameter instanceof ExtendedParameterReflection) { + continue; + } + + $baseDirectories = $this->getFileReferenceBaseDirectories($parameter); + if ($baseDirectories === null) { + continue; + } + + $argumentValueType ??= $scope->getType($argumentValue); + foreach ($argumentValueType->getConstantStrings() as $constantString) { + $path = $constantString->getValue(); + if ($path === '') { + continue; + } + if ($this->fileExistenceChecker->pathExists($path, $scriptDirectory, $baseDirectories)) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf( + 'Path "%s" passed to %s does not exist.', + $path, + lcfirst($this->describeParameter($parameter, $argumentName ?? $i + 1)), + )) + ->identifier('argument.fileReference') + ->line($argumentLine) + ->build(); + } + } + + return $errors; + } + + /** + * @return list|null Base directories from the attribute's $basePath, or null when the parameter is not a file reference + */ + private function getFileReferenceBaseDirectories(ExtendedParameterReflection $parameter): ?array + { + $isFileReference = false; + $baseDirectories = []; + foreach ($parameter->getAttributes() as $attribute) { + if ($attribute->getName() !== self::FILE_REFERENCE_ATTRIBUTE) { + continue; + } + $isFileReference = true; + + $basePathType = $attribute->getArgumentTypes()['basePath'] ?? null; + if ($basePathType === null) { + continue; + } + foreach ($basePathType->getConstantStrings() as $basePath) { + if ($basePath->getValue() === '') { + continue; + } + $baseDirectories[] = $basePath->getValue(); + } + } + + if (!$isFileReference) { + return null; + } + + return $baseDirectories; + } + private function describeParameter(ParameterReflection $parameter, int|string|null $positionOrNamed): string { $parts = []; diff --git a/src/Rules/Keywords/RequireFileExistsRule.php b/src/Rules/Keywords/RequireFileExistsRule.php index 4ea63f57159..6e2c8ae9e24 100644 --- a/src/Rules/Keywords/RequireFileExistsRule.php +++ b/src/Rules/Keywords/RequireFileExistsRule.php @@ -11,6 +11,7 @@ use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\RegisteredRule; +use PHPStan\File\FileExistenceChecker; use PHPStan\File\FileHelper; use PHPStan\Node\Printer\ExprPrinter; use PHPStan\Rules\IdentifierRuleError; @@ -18,13 +19,8 @@ use PHPStan\Rules\RuleErrorBuilder; use PHPStan\ShouldNotHappenException; use PHPStan\Type\Constant\ConstantStringType; -use function array_merge; use function dirname; -use function explode; -use function get_include_path; -use function is_file; use function sprintf; -use const PATH_SEPARATOR; /** * @implements Rule @@ -47,12 +43,11 @@ final class RequireFileExistsRule implements Rule ]; public function __construct( - #[AutowiredParameter] - private string $currentWorkingDirectory, private ExprPrinter $exprPrinter, #[AutowiredParameter(ref: '%featureToggles.magicDirInInclude%')] private bool $checkMagicDirInInclude, private FileHelper $fileHelper, + private FileExistenceChecker $fileExistenceChecker, ) { } @@ -91,37 +86,9 @@ public function processNode(Node $node, Scope $scope): array return $errors; } - /** - * We cannot use `stream_resolve_include_path` as it works based on the calling script. - * This method simulates the behavior of `stream_resolve_include_path` but for the given scope. - * The priority order is the following: - * 1. The current working directory. - * 2. The include path. - * 3. The path of the script that is being executed. - */ private function doesFileExist(string $path, Scope $scope): bool { - $directories = array_merge( - [$this->currentWorkingDirectory], - explode(PATH_SEPARATOR, get_include_path()), - [dirname($scope->getFile())], - ); - - foreach ($directories as $directory) { - if ($this->doesFileExistForDirectory($path, $directory)) { - return true; - } - } - - return false; - } - - private function doesFileExistForDirectory(string $path, string $workingDirectory): bool - { - $fileHelper = new FileHelper($workingDirectory); - $absolutePath = $fileHelper->absolutizePath($path); - - return is_file($absolutePath); + return $this->fileExistenceChecker->fileExists($path, dirname($scope->getFile())); } private function getErrorMessage(Include_ $node, string $filePath): IdentifierRuleError diff --git a/tests/PHPStan/Analyser/AnalyserIntegrationTest.php b/tests/PHPStan/Analyser/AnalyserIntegrationTest.php index 06788c89415..147157b1e36 100644 --- a/tests/PHPStan/Analyser/AnalyserIntegrationTest.php +++ b/tests/PHPStan/Analyser/AnalyserIntegrationTest.php @@ -1128,6 +1128,14 @@ public static function getAdditionalConfigFiles(): array ]; } + public function testFileReferenceAttribute(): void + { + $errors = $this->runAnalyse(__DIR__ . '/data/file-reference-attribute.php'); + $this->assertCount(1, $errors); + $this->assertSame('Path "file-reference-attribute-missing.php" passed to parameter #1 $path does not exist.', $errors[0]->getMessage()); + $this->assertSame(26, $errors[0]->getLine()); + } + public function testBug8004(): void { // false positive diff --git a/tests/PHPStan/Analyser/data/file-reference-attribute.php b/tests/PHPStan/Analyser/data/file-reference-attribute.php new file mode 100644 index 00000000000..8c0e8985543 --- /dev/null +++ b/tests/PHPStan/Analyser/data/file-reference-attribute.php @@ -0,0 +1,28 @@ += 8.0 + +namespace JetBrains\PhpStorm { + + #[\Attribute(\Attribute::TARGET_PARAMETER)] + class FileReference + { + + public function __construct(string $basePath = '') + { + } + + } + +} + +namespace FileReferenceIntegration { + + use JetBrains\PhpStorm\FileReference; + + function loadFile(#[FileReference] string $path): void + { + } + + loadFile('file-reference-attribute.php'); + loadFile('file-reference-attribute-missing.php'); + +} diff --git a/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php b/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php index 105257b4993..972c7fe3155 100644 --- a/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php +++ b/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php @@ -2,6 +2,7 @@ namespace PHPStan\Rules\Classes; +use PHPStan\File\FileExistenceChecker; use PHPStan\Rules\ClassCaseSensitivityCheck; use PHPStan\Rules\ClassForbiddenNameCheck; use PHPStan\Rules\ClassNameCheck; @@ -49,6 +50,7 @@ protected function getRule(): Rule checkArgumentsPassedByReference: true, checkExtraArguments: true, checkMissingTypehints: true, + fileExistenceChecker: self::getContainer()->getByType(FileExistenceChecker::class), ), new ClassNameCheck( new ClassCaseSensitivityCheck($reflectionProvider, checkInternalClassCaseSensitivity: true), @@ -728,4 +730,18 @@ public function testInstantiationWithNonObjectType(): void ]); } + public function testFileReferenceAttribute(): void + { + $this->analyse([__DIR__ . '/../data/file-reference.php'], [ + [ + 'Path "file-reference-missing.php" passed to parameter #1 $path does not exist.', + 71, + ], + [ + 'Path "file-reference-missing.php" passed to parameter #1 $path does not exist.', + 84, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index 44bc0a56d08..896c3c6fb62 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -2,6 +2,7 @@ namespace PHPStan\Rules\Functions; +use PHPStan\File\FileExistenceChecker; use PHPStan\Rules\FunctionCallParametersCheck; use PHPStan\Rules\NullsafeCheck; use PHPStan\Rules\PhpDoc\UnresolvableTypeHelper; @@ -49,6 +50,7 @@ protected function getRule(): Rule checkArgumentsPassedByReference: true, checkExtraArguments: true, checkMissingTypehints: true, + fileExistenceChecker: self::getContainer()->getByType(FileExistenceChecker::class), ), ); } @@ -3029,4 +3031,22 @@ public function testBug11494(): void ]); } + public function testFileReferenceAttribute(): void + { + $this->analyse([__DIR__ . '/../data/file-reference.php'], [ + [ + 'Path "file-reference-missing.php" passed to parameter #1 $path does not exist.', + 57, + ], + [ + 'Path "file-reference-nested.php" passed to parameter #1 $path does not exist.', + 59, + ], + [ + 'Path "file-reference-missing.php" passed to parameter #1 $path does not exist.', + 67, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Keywords/RequireFileExistsRuleNoConstantPathTest.php b/tests/PHPStan/Rules/Keywords/RequireFileExistsRuleNoConstantPathTest.php index f953876bae3..5e12029571c 100644 --- a/tests/PHPStan/Rules/Keywords/RequireFileExistsRuleNoConstantPathTest.php +++ b/tests/PHPStan/Rules/Keywords/RequireFileExistsRuleNoConstantPathTest.php @@ -2,6 +2,7 @@ namespace PHPStan\Rules\Keywords; +use PHPStan\File\FileExistenceChecker; use PHPStan\File\FileHelper; use PHPStan\Node\Printer\ExprPrinter; use PHPStan\Rules\Rule; @@ -18,10 +19,10 @@ class RequireFileExistsRuleNoConstantPathTest extends RuleTestCase protected function getRule(): Rule { return new RequireFileExistsRule( - $this->currentWorkingDirectory, self::getContainer()->getByType(ExprPrinter::class), true, self::getContainer()->getByType(FileHelper::class), + new FileExistenceChecker($this->currentWorkingDirectory), ); } diff --git a/tests/PHPStan/Rules/Keywords/RequireFileExistsRuleTest.php b/tests/PHPStan/Rules/Keywords/RequireFileExistsRuleTest.php index 7cad2911c20..a185b943da8 100644 --- a/tests/PHPStan/Rules/Keywords/RequireFileExistsRuleTest.php +++ b/tests/PHPStan/Rules/Keywords/RequireFileExistsRuleTest.php @@ -2,6 +2,7 @@ namespace PHPStan\Rules\Keywords; +use PHPStan\File\FileExistenceChecker; use PHPStan\File\FileHelper; use PHPStan\Node\Printer\ExprPrinter; use PHPStan\Rules\Rule; @@ -23,10 +24,10 @@ class RequireFileExistsRuleTest extends RuleTestCase protected function getRule(): Rule { return new RequireFileExistsRule( - $this->currentWorkingDirectory, self::getContainer()->getByType(ExprPrinter::class), true, self::getContainer()->getByType(FileHelper::class), + new FileExistenceChecker($this->currentWorkingDirectory), ); } diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index eda813a24b2..1593b698298 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -2,6 +2,7 @@ namespace PHPStan\Rules\Methods; +use PHPStan\File\FileExistenceChecker; use PHPStan\Rules\FunctionCallParametersCheck; use PHPStan\Rules\NullsafeCheck; use PHPStan\Rules\PhpDoc\UnresolvableTypeHelper; @@ -59,6 +60,7 @@ protected function getRule(): Rule checkArgumentsPassedByReference: true, checkExtraArguments: true, checkMissingTypehints: true, + fileExistenceChecker: self::getContainer()->getByType(FileExistenceChecker::class), ), ); } @@ -4258,4 +4260,17 @@ public function testBug14893(): void ]); } + public function testFileReferenceAttribute(): void + { + $this->checkThisOnly = false; + $this->checkNullables = true; + $this->checkUnionTypes = true; + $this->analyse([__DIR__ . '/../data/file-reference.php'], [ + [ + 'Path "file-reference-missing.php" passed to parameter #1 $path does not exist.', + 76, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php index cc31101ece2..56e7afff4c7 100644 --- a/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php @@ -2,6 +2,7 @@ namespace PHPStan\Rules\Methods; +use PHPStan\File\FileExistenceChecker; use PHPStan\Rules\ClassCaseSensitivityCheck; use PHPStan\Rules\ClassForbiddenNameCheck; use PHPStan\Rules\ClassNameCheck; @@ -70,6 +71,7 @@ protected function getRule(): Rule checkArgumentsPassedByReference: true, checkExtraArguments: true, checkMissingTypehints: true, + fileExistenceChecker: self::getContainer()->getByType(FileExistenceChecker::class), ), ); } @@ -1057,4 +1059,15 @@ public function testClassExistOnCall(): void $this->analyse([__DIR__ . '/data/class-exists-on-static-call.php'], []); } + public function testFileReferenceAttribute(): void + { + $this->checkThisOnly = false; + $this->analyse([__DIR__ . '/../data/file-reference.php'], [ + [ + 'Path "file-reference-missing.php" passed to parameter #1 $path does not exist.', + 80, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/data/file-reference-base/file-reference-nested.php b/tests/PHPStan/Rules/data/file-reference-base/file-reference-nested.php new file mode 100644 index 00000000000..b576167ec6b --- /dev/null +++ b/tests/PHPStan/Rules/data/file-reference-base/file-reference-nested.php @@ -0,0 +1,3 @@ += 8.0 + +namespace JetBrains\PhpStorm { + + #[\Attribute(\Attribute::TARGET_PARAMETER)] + class FileReference + { + + public function __construct(string $basePath = '') + { + } + + } + +} + +namespace FileReferenceTest { + + use JetBrains\PhpStorm\FileReference; + + function loadFile(#[FileReference] string $path): void + { + } + + function loadFileWithBasePath(#[FileReference('file-reference-base')] string $path): void + { + } + + class Loader + { + + public function __construct(#[FileReference] string $path) + { + } + + public function load(#[FileReference] string $path): void + { + } + + public static function loadStatic(#[FileReference] string $path): void + { + } + + } + + class PromotedLoader + { + + public function __construct(#[FileReference] public string $path) + { + } + + } + + // function calls + loadFile('file-reference-existing.php'); + loadFile('file-reference-missing.php'); + loadFile('file-reference-base'); + loadFile('file-reference-nested.php'); + + // non-constant path is not checked + $dynamicPath = (string) rand(); + loadFile($dynamicPath); + + // base path resolves against the analysed file directory + loadFileWithBasePath('file-reference-nested.php'); + loadFileWithBasePath('file-reference-missing.php'); + + // constructor calls + new Loader('file-reference-existing.php'); + new Loader('file-reference-missing.php'); + + // method calls + $loader = new Loader('file-reference-existing.php'); + $loader->load('file-reference-existing.php'); + $loader->load('file-reference-missing.php'); + + // static calls + Loader::loadStatic('file-reference-existing.php'); + Loader::loadStatic('file-reference-missing.php'); + + // promoted constructor property + new PromotedLoader('file-reference-existing.php'); + new PromotedLoader('file-reference-missing.php'); + +} From 5dd79a6c49104319015aeb3137649c6139e75515 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 18 Jul 2026 17:45:00 +0000 Subject: [PATCH 2/7] Guard #[FileReference] argument check behind checkFileReferences feature toggle The check for constant-string arguments passed to #[JetBrains\PhpStorm\FileReference] parameters is now only enabled in bleeding edge via the new %featureToggles.checkFileReferences% toggle. Co-Authored-By: Claude Opus 4.8 --- conf/bleedingEdge.neon | 1 + conf/config.neon | 1 + conf/parametersSchema.neon | 1 + src/Rules/FunctionCallParametersCheck.php | 4 +++- tests/PHPStan/Rules/Classes/InstantiationRuleTest.php | 1 + .../Rules/Functions/CallToFunctionParametersRuleTest.php | 1 + tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php | 1 + tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php | 1 + 8 files changed, 10 insertions(+), 1 deletion(-) diff --git a/conf/bleedingEdge.neon b/conf/bleedingEdge.neon index 140a90ae453..fe739660996 100644 --- a/conf/bleedingEdge.neon +++ b/conf/bleedingEdge.neon @@ -24,3 +24,4 @@ parameters: newOnNonObject: true unnecessaryNullCoalesce: true finiteTypesInHaystack: true + checkFileReferences: true diff --git a/conf/config.neon b/conf/config.neon index 3012ca68af2..e640f7c3eb3 100644 --- a/conf/config.neon +++ b/conf/config.neon @@ -50,6 +50,7 @@ parameters: newOnNonObject: false unnecessaryNullCoalesce: false finiteTypesInHaystack: false + checkFileReferences: false fileExtensions: - php checkAdvancedIsset: false diff --git a/conf/parametersSchema.neon b/conf/parametersSchema.neon index cf5d3b4ef6b..812756609f9 100644 --- a/conf/parametersSchema.neon +++ b/conf/parametersSchema.neon @@ -53,6 +53,7 @@ parametersSchema: newOnNonObject: bool() unnecessaryNullCoalesce: bool() finiteTypesInHaystack: bool() + checkFileReferences: bool() ]) fileExtensions: listOf(string()) checkAdvancedIsset: bool() diff --git a/src/Rules/FunctionCallParametersCheck.php b/src/Rules/FunctionCallParametersCheck.php index fa37ba918fe..17c4dfc6668 100644 --- a/src/Rules/FunctionCallParametersCheck.php +++ b/src/Rules/FunctionCallParametersCheck.php @@ -68,6 +68,8 @@ public function __construct( #[AutowiredParameter] private bool $checkMissingTypehints, private ?FileExistenceChecker $fileExistenceChecker = null, + #[AutowiredParameter(ref: '%featureToggles.checkFileReferences%')] + private bool $checkFileReferences = false, ) { } @@ -800,7 +802,7 @@ private function processArguments( */ private function checkFileReferences(array $argumentsWithParameters, Scope $scope): array { - if ($this->fileExistenceChecker === null) { + if (!$this->checkFileReferences || $this->fileExistenceChecker === null) { return []; } diff --git a/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php b/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php index 972c7fe3155..f720c39c8c4 100644 --- a/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php +++ b/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php @@ -51,6 +51,7 @@ protected function getRule(): Rule checkExtraArguments: true, checkMissingTypehints: true, fileExistenceChecker: self::getContainer()->getByType(FileExistenceChecker::class), + checkFileReferences: true, ), new ClassNameCheck( new ClassCaseSensitivityCheck($reflectionProvider, checkInternalClassCaseSensitivity: true), diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index 896c3c6fb62..0801c9521ab 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -51,6 +51,7 @@ protected function getRule(): Rule checkExtraArguments: true, checkMissingTypehints: true, fileExistenceChecker: self::getContainer()->getByType(FileExistenceChecker::class), + checkFileReferences: true, ), ); } diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index 1593b698298..190916100d7 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -61,6 +61,7 @@ protected function getRule(): Rule checkExtraArguments: true, checkMissingTypehints: true, fileExistenceChecker: self::getContainer()->getByType(FileExistenceChecker::class), + checkFileReferences: true, ), ); } diff --git a/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php index 56e7afff4c7..85119d52cd5 100644 --- a/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php @@ -72,6 +72,7 @@ protected function getRule(): Rule checkExtraArguments: true, checkMissingTypehints: true, fileExistenceChecker: self::getContainer()->getByType(FileExistenceChecker::class), + checkFileReferences: true, ), ); } From c4b753983d5ad58b0244e747f87c623a618f108f Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 18 Jul 2026 18:01:46 +0000 Subject: [PATCH 3/7] Exempt file-existence-testing functions from the #[FileReference] check Functions like is_file()/file_exists() legitimately receive paths that may not exist, so reporting argument.fileReference for them would be a false positive once phpstorm-stubs annotate them with #[FileReference] (https://youtrack.jetbrains.com/issue/WI-85516). The list of such functions, previously private to RequireFileExistsRule, now lives on FileExistenceChecker and is shared by both the include/require guard and the call-site check. Co-Authored-By: Claude Opus 4.8 --- src/File/FileExistenceChecker.php | 16 +++++++++++++ src/Rules/FunctionCallParametersCheck.php | 25 ++++++++++++++++++-- src/Rules/Keywords/RequireFileExistsRule.php | 15 +----------- tests/PHPStan/Rules/data/file-reference.php | 14 +++++++++++ 4 files changed, 54 insertions(+), 16 deletions(-) diff --git a/src/File/FileExistenceChecker.php b/src/File/FileExistenceChecker.php index 4c0f7f4e235..5cf172d09a0 100644 --- a/src/File/FileExistenceChecker.php +++ b/src/File/FileExistenceChecker.php @@ -25,6 +25,22 @@ final class FileExistenceChecker { + /** + * Functions whose whole purpose is to test whether a path exists, so passing + * them a non-existent path is expected and must not be reported. + * + * Relevant once phpstorm-stubs annotate these with #[JetBrains\PhpStorm\FileReference]: + * @see https://youtrack.jetbrains.com/issue/WI-85516/FileReference-and-builtin-functions-like-isfile + */ + public const FILE_EXISTENCE_FUNCTIONS = [ + 'file_exists', + 'is_file', + 'is_readable', + 'is_writable', + 'is_writeable', + 'is_executable', + ]; + public function __construct( #[AutowiredParameter] private string $currentWorkingDirectory, diff --git a/src/Rules/FunctionCallParametersCheck.php b/src/Rules/FunctionCallParametersCheck.php index 17c4dfc6668..c994839d9f6 100644 --- a/src/Rules/FunctionCallParametersCheck.php +++ b/src/Rules/FunctionCallParametersCheck.php @@ -46,6 +46,7 @@ use function lcfirst; use function max; use function sprintf; +use function strtolower; #[AutowiredService] final class FunctionCallParametersCheck @@ -331,7 +332,7 @@ public function check( $errors[] = $error; } - foreach ($this->checkFileReferences($argumentsWithParameters, $scope) as $error) { + foreach ($this->checkFileReferences($argumentsWithParameters, $scope, $funcCall) as $error) { $errors[] = $error; } @@ -800,12 +801,17 @@ private function processArguments( * @param array $argumentsWithParameters * @return list */ - private function checkFileReferences(array $argumentsWithParameters, Scope $scope): array + private function checkFileReferences(array $argumentsWithParameters, Scope $scope, Node\Expr\FuncCall|Node\Expr\MethodCall|Node\Expr\StaticCall|Node\Expr\New_ $funcCall): array { if (!$this->checkFileReferences || $this->fileExistenceChecker === null) { return []; } + if ($this->isFileExistenceTestingCall($funcCall, $scope)) { + // e.g. is_file()/file_exists() legitimately receive paths that may not exist + return []; + } + $errors = []; $scriptDirectory = dirname($scope->getFile()); foreach ($argumentsWithParameters as $i => [$argumentValue, $argumentValueType, $unpack, $argumentName, $argumentLine, $parameter]) { @@ -845,6 +851,21 @@ private function checkFileReferences(array $argumentsWithParameters, Scope $scop return $errors; } + private function isFileExistenceTestingCall(Node\Expr\FuncCall|Node\Expr\MethodCall|Node\Expr\StaticCall|Node\Expr\New_ $funcCall, Scope $scope): bool + { + if (!$funcCall instanceof Node\Expr\FuncCall || !$funcCall->name instanceof Node\Name) { + return false; + } + + if (!$this->reflectionProvider->hasFunction($funcCall->name, $scope)) { + return false; + } + + $functionName = strtolower($this->reflectionProvider->getFunction($funcCall->name, $scope)->getName()); + + return in_array($functionName, FileExistenceChecker::FILE_EXISTENCE_FUNCTIONS, true); + } + /** * @return list|null Base directories from the attribute's $basePath, or null when the parameter is not a file reference */ diff --git a/src/Rules/Keywords/RequireFileExistsRule.php b/src/Rules/Keywords/RequireFileExistsRule.php index 6e2c8ae9e24..b9c4b4c519c 100644 --- a/src/Rules/Keywords/RequireFileExistsRule.php +++ b/src/Rules/Keywords/RequireFileExistsRule.php @@ -29,19 +29,6 @@ final class RequireFileExistsRule implements Rule { - /** - * Functions that, when they return true, guarantee the path exists on the - * filesystem, so guarding a require/include with them suppresses the error. - */ - private const FILE_EXISTENCE_FUNCTIONS = [ - 'file_exists', - 'is_file', - 'is_readable', - 'is_writable', - 'is_writeable', - 'is_executable', - ]; - public function __construct( private ExprPrinter $exprPrinter, #[AutowiredParameter(ref: '%featureToggles.magicDirInInclude%')] @@ -170,7 +157,7 @@ private function resolveFilePaths(Expr $expr, Scope $scope, bool &$magicDirFallb private function isInFileExists(Include_ $node, Scope $scope): bool { - foreach (self::FILE_EXISTENCE_FUNCTIONS as $funcName) { + foreach (FileExistenceChecker::FILE_EXISTENCE_FUNCTIONS as $funcName) { $expr = new FuncCall(new FullyQualified($funcName), [ new Arg($node->expr), ]); diff --git a/tests/PHPStan/Rules/data/file-reference.php b/tests/PHPStan/Rules/data/file-reference.php index 3be1c7779a9..2ebe109d0b2 100644 --- a/tests/PHPStan/Rules/data/file-reference.php +++ b/tests/PHPStan/Rules/data/file-reference.php @@ -84,3 +84,17 @@ public function __construct(#[FileReference] public string $path) new PromotedLoader('file-reference-missing.php'); } + +namespace { + + // File-existence-testing functions legitimately receive paths that may not + // exist, so they are exempt even when annotated with #[FileReference]. + // See https://youtrack.jetbrains.com/issue/WI-85516 + function is_file(#[\JetBrains\PhpStorm\FileReference] string $path): bool + { + return true; + } + + is_file('file-reference-missing.php'); + +} From dcc65925de350bb0201f802508d1f9c5b00d3f64 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 18 Jul 2026 18:03:48 +0000 Subject: [PATCH 4/7] Document why the FileReference attribute is declared locally in test fixtures PHPStan cannot resolve JetBrains\PhpStorm\FileReference from phpstorm-stubs: the attribute ships only in the IDE-only meta/attributes/ directory, which is not part of the autoloaded PhpStormStubsMap, and PHPStan stub files cannot register a brand-new class. The local declaration is also functionally required so the constructor is reflectable and the positional $basePath argument maps to its name. Note the removal condition so the fixture declaration can be dropped once the attribute is registered in PhpStormStubsMap. Co-Authored-By: Claude Opus 4.8 --- tests/PHPStan/Analyser/data/file-reference-attribute.php | 2 +- tests/PHPStan/Rules/data/file-reference.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/PHPStan/Analyser/data/file-reference-attribute.php b/tests/PHPStan/Analyser/data/file-reference-attribute.php index 8c0e8985543..68b05130442 100644 --- a/tests/PHPStan/Analyser/data/file-reference-attribute.php +++ b/tests/PHPStan/Analyser/data/file-reference-attribute.php @@ -1,7 +1,7 @@ = 8.0 namespace JetBrains\PhpStorm { - + // Declared locally: PHPStan does not autoload phpstorm-stubs' meta/attributes/FileReference.php (it is absent from PhpStormStubsMap). Drop this once the attribute is registered there. #[\Attribute(\Attribute::TARGET_PARAMETER)] class FileReference { diff --git a/tests/PHPStan/Rules/data/file-reference.php b/tests/PHPStan/Rules/data/file-reference.php index 2ebe109d0b2..6a6a880c877 100644 --- a/tests/PHPStan/Rules/data/file-reference.php +++ b/tests/PHPStan/Rules/data/file-reference.php @@ -1,7 +1,7 @@ = 8.0 namespace JetBrains\PhpStorm { - + // Declared locally: PHPStan does not autoload phpstorm-stubs' meta/attributes/FileReference.php (it is absent from PhpStormStubsMap). Drop this once the attribute is registered there. #[\Attribute(\Attribute::TARGET_PARAMETER)] class FileReference { From 4cd51e5ab3dc72cc9b5f443ab6e9b9e59dd31a2a Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sat, 18 Jul 2026 20:06:37 +0200 Subject: [PATCH 5/7] Update FileExistenceChecker.php --- src/File/FileExistenceChecker.php | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/File/FileExistenceChecker.php b/src/File/FileExistenceChecker.php index 5cf172d09a0..dbc31d58f9b 100644 --- a/src/File/FileExistenceChecker.php +++ b/src/File/FileExistenceChecker.php @@ -48,12 +48,9 @@ public function __construct( { } - /** - * @param list $baseDirectories - */ - public function fileExists(string $path, string $scriptDirectory, array $baseDirectories = []): bool + public function fileExists(string $path, string $scriptDirectory): bool { - return $this->exists($path, $scriptDirectory, $baseDirectories, false); + return $this->exists($path, $scriptDirectory, [], false); } /** @@ -61,7 +58,7 @@ public function fileExists(string $path, string $scriptDirectory, array $baseDir * * @param list $baseDirectories */ - public function pathExists(string $path, string $scriptDirectory, array $baseDirectories = []): bool + public function pathExists(string $path, string $scriptDirectory, array $baseDirectories): bool { return $this->exists($path, $scriptDirectory, $baseDirectories, true); } From aab2b28401f5436cd0248ac2d3697d84fb0345e4 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 18 Jul 2026 18:12:03 +0000 Subject: [PATCH 6/7] Remove #[FileReference] test from AnalyserIntegrationTest AnalyserIntegrationTest is reserved for crash bugs. The feature is already covered end-to-end by the rule tests (CallToFunctionParametersRuleTest, CallMethodsRuleTest, CallStaticMethodsRuleTest, InstantiationRuleTest). Co-Authored-By: Claude Opus 4.8 --- build/collision-detector.json | 1 - .../Analyser/AnalyserIntegrationTest.php | 8 ------ .../data/file-reference-attribute.php | 28 ------------------- 3 files changed, 37 deletions(-) delete mode 100644 tests/PHPStan/Analyser/data/file-reference-attribute.php diff --git a/build/collision-detector.json b/build/collision-detector.json index a9ab4d02006..390d572c254 100644 --- a/build/collision-detector.json +++ b/build/collision-detector.json @@ -10,7 +10,6 @@ "../tests/PHPStan/Rules/Functions/data/duplicate-function.php", "../tests/PHPStan/Rules/Classes/data/duplicate-class.php", "../tests/PHPStan/Rules/data/file-reference.php", - "../tests/PHPStan/Analyser/data/file-reference-attribute.php", "../tests/PHPStan/Rules/Names/data/multiple-namespaces.php", "../tests/PHPStan/Rules/Names/data/no-namespace.php", "../tests/notAutoloaded", diff --git a/tests/PHPStan/Analyser/AnalyserIntegrationTest.php b/tests/PHPStan/Analyser/AnalyserIntegrationTest.php index 147157b1e36..06788c89415 100644 --- a/tests/PHPStan/Analyser/AnalyserIntegrationTest.php +++ b/tests/PHPStan/Analyser/AnalyserIntegrationTest.php @@ -1128,14 +1128,6 @@ public static function getAdditionalConfigFiles(): array ]; } - public function testFileReferenceAttribute(): void - { - $errors = $this->runAnalyse(__DIR__ . '/data/file-reference-attribute.php'); - $this->assertCount(1, $errors); - $this->assertSame('Path "file-reference-attribute-missing.php" passed to parameter #1 $path does not exist.', $errors[0]->getMessage()); - $this->assertSame(26, $errors[0]->getLine()); - } - public function testBug8004(): void { // false positive diff --git a/tests/PHPStan/Analyser/data/file-reference-attribute.php b/tests/PHPStan/Analyser/data/file-reference-attribute.php deleted file mode 100644 index 68b05130442..00000000000 --- a/tests/PHPStan/Analyser/data/file-reference-attribute.php +++ /dev/null @@ -1,28 +0,0 @@ -= 8.0 - -namespace JetBrains\PhpStorm { - // Declared locally: PHPStan does not autoload phpstorm-stubs' meta/attributes/FileReference.php (it is absent from PhpStormStubsMap). Drop this once the attribute is registered there. - #[\Attribute(\Attribute::TARGET_PARAMETER)] - class FileReference - { - - public function __construct(string $basePath = '') - { - } - - } - -} - -namespace FileReferenceIntegration { - - use JetBrains\PhpStorm\FileReference; - - function loadFile(#[FileReference] string $path): void - { - } - - loadFile('file-reference-attribute.php'); - loadFile('file-reference-attribute-missing.php'); - -} From ac312c3cf9bbf79d7a8140534c87696231f9fb4b Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sat, 18 Jul 2026 20:16:03 +0200 Subject: [PATCH 7/7] Update file-reference.php --- tests/PHPStan/Rules/data/file-reference.php | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/tests/PHPStan/Rules/data/file-reference.php b/tests/PHPStan/Rules/data/file-reference.php index 6a6a880c877..be233ae27a2 100644 --- a/tests/PHPStan/Rules/data/file-reference.php +++ b/tests/PHPStan/Rules/data/file-reference.php @@ -84,17 +84,3 @@ public function __construct(#[FileReference] public string $path) new PromotedLoader('file-reference-missing.php'); } - -namespace { - - // File-existence-testing functions legitimately receive paths that may not - // exist, so they are exempt even when annotated with #[FileReference]. - // See https://youtrack.jetbrains.com/issue/WI-85516 - function is_file(#[\JetBrains\PhpStorm\FileReference] string $path): bool - { - return true; - } - - is_file('file-reference-missing.php'); - -}