Skip to content
2 changes: 2 additions & 0 deletions build/collision-detector.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
87 changes: 87 additions & 0 deletions src/File/FileExistenceChecker.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<?php declare(strict_types = 1);

namespace PHPStan\File;

use PHPStan\DependencyInjection\AutowiredParameter;
use PHPStan\DependencyInjection\AutowiredService;
use function array_merge;
use function explode;
use function get_include_path;
use function is_dir;
use function is_file;
use const PATH_SEPARATOR;

/**
* Resolves a possibly-relative path the same way PHP would at runtime and checks whether it exists.
*
* We cannot use stream_resolve_include_path() because it works based on the calling script.
* This mirrors its behavior but for an arbitrary script directory. The priority order is:
* 1. The base directories (e.g. from an attribute argument).
* 2. The current working directory.
* 3. The include path.
* 4. The directory of the script that is being analysed.
*/
#[AutowiredService]
final class FileExistenceChecker
{

public function __construct(
#[AutowiredParameter]
private string $currentWorkingDirectory,
)
{
}

/**
* @param list<string> $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<string> $baseDirectories
*/
public function pathExists(string $path, string $scriptDirectory, array $baseDirectories = []): bool
{
return $this->exists($path, $scriptDirectory, $baseDirectories, true);
}

/**
* @param list<string> $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;
}

}
93 changes: 93 additions & 0 deletions src/Rules/FunctionCallParametersCheck.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -49,6 +51,8 @@
final class FunctionCallParametersCheck
{

private const FILE_REFERENCE_ATTRIBUTE = 'JetBrains\PhpStorm\FileReference';

public function __construct(
private RuleLevelHelper $ruleLevelHelper,
private NullsafeCheck $nullsafeCheck,
Expand All @@ -63,6 +67,7 @@ public function __construct(
private bool $checkExtraArguments,
#[AutowiredParameter]
private bool $checkMissingTypehints,
private ?FileExistenceChecker $fileExistenceChecker = null,
)
{
}
Expand Down Expand Up @@ -324,6 +329,10 @@ public function check(
$errors[] = $error;
}

foreach ($this->checkFileReferences($argumentsWithParameters, $scope) as $error) {
Comment thread
staabm marked this conversation as resolved.
Outdated
$errors[] = $error;
}

if (!$this->checkArgumentTypes && !$this->checkArgumentsPassedByReference) {
return $errors;
}
Expand Down Expand Up @@ -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<int, array{Expr, Type|null, bool, (string|null), int, (ParameterReflection|null), (ParameterReflection|null)}> $argumentsWithParameters
* @return list<IdentifierRuleError>
*/
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<string>|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 = [];
Expand Down
39 changes: 3 additions & 36 deletions src/Rules/Keywords/RequireFileExistsRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,16 @@
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;
use PHPStan\Rules\Rule;
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<Include_>
Expand All @@ -47,12 +43,11 @@ final class RequireFileExistsRule implements Rule
];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

memo to me:

in case the above file-existence functions also get the #[FileReference] attributes in phpstorm stubs, this might mean in the future we need to adjust this rule

https://youtrack.jetbrains.com/issue/WI-85516/FileReference-and-builtin-functions-like-isfile

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Here's what I did to address the reviewer's memo.

The concern

staabm left a memo warning that if phpstorm-stubs eventually add #[JetBrains\PhpStorm\FileReference] to file-existence-testing built-ins (is_file, file_exists, …) — as tracked in WI-85516 — the new check would start reporting argument.fileReference false positives: it's nonsensical to complain that a path "does not exist" for a function whose entire purpose is to test whether it exists.

I confirmed this is a real, reachable regression by redeclaring is_file with the attribute and running the analyser under bleeding edge — it did emit Path "..." does not exist. before the fix.

The fix (one logical commit)

  • src/File/FileExistenceChecker.php — moved the list of existence-testing functions (previously a private const in RequireFileExistsRule) here as a shared public const FILE_EXISTENCE_FUNCTIONS, with a doc comment linking WI-85516. Both the include/require guard and the new call-site check now share one source of truth.
  • src/Rules/Keywords/RequireFileExistsRule.php — references the shared constant instead of its own copy.
  • src/Rules/FunctionCallParametersCheck.phpcheckFileReferences() now skips the whole call when it resolves to one of those functions (isFileExistenceTestingCall()), so annotated existence-testers never trigger the check.
  • tests/PHPStan/Rules/data/file-reference.php — added a global-namespace is_file() redeclared with #[FileReference] and called with a missing path; the CallToFunctionParametersRuleTest expectations stay at 3 errors, proving the new call is not reported.

Verification

  • Confirmed the test fails without the guard (a 4th error appears at the new line) and passes with it.
  • make tests: 17611 passed (97 skipped), no failures.
  • make phpstan: No errors.

Committed as c4b753983 and pushed to create-pull-request/patch-q6sl023.

One note for the reviewer: this covers the false-positive risk proactively. The separate memo about dropping the in-fixture FileReference attribute re-declaration once phpstorm-stubs ship it remains genuinely future work (it depends on those stubs existing), so I left it untouched.


public function __construct(
#[AutowiredParameter]
private string $currentWorkingDirectory,
private ExprPrinter $exprPrinter,
#[AutowiredParameter(ref: '%featureToggles.magicDirInInclude%')]
private bool $checkMagicDirInInclude,
private FileHelper $fileHelper,
private FileExistenceChecker $fileExistenceChecker,
)
{
}
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions tests/PHPStan/Analyser/AnalyserIntegrationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1128,6 +1128,14 @@ public static function getAdditionalConfigFiles(): array
];
}

public function testFileReferenceAttribute(): void

@staabm staabm Jul 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AnalyserIntegrationTest is reserved for crash bugs - remove this test

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. I addressed the review:

Change

Removed testFileReferenceAttribute from AnalyserIntegrationTest — the reviewer noted this test class is reserved for crash bugs, not feature coverage.

Specifically:

  • Deleted the test method from tests/PHPStan/Analyser/AnalyserIntegrationTest.php.
  • Deleted its data fixture tests/PHPStan/Analyser/data/file-reference-attribute.php.
  • Removed that fixture's entry from build/collision-detector.json (it was only excluded there because it redeclared the FileReference attribute).

The feature remains fully covered by the four rule tests (CallToFunctionParametersRuleTest, CallMethodsRuleTest, CallStaticMethodsRuleTest, InstantiationRuleTest), which each set checkFileReferences: true and pull the real FileExistenceChecker from the container — so the autowired-dependency wiring is still exercised.

Verification

  • File-reference rule tests: pass (3 tests).
  • AnalyserIntegrationTest: pass (184 tests) after removal.
  • make phpstan: No errors.

Committed as aab2b2840 and pushed to create-pull-request/patch-q6sl023.

{
$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
Expand Down
28 changes: 28 additions & 0 deletions tests/PHPStan/Analyser/data/file-reference-attribute.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php declare(strict_types = 1); // lint >= 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');

}
16 changes: 16 additions & 0 deletions tests/PHPStan/Rules/Classes/InstantiationRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace PHPStan\Rules\Classes;

use PHPStan\File\FileExistenceChecker;
use PHPStan\Rules\ClassCaseSensitivityCheck;
use PHPStan\Rules\ClassForbiddenNameCheck;
use PHPStan\Rules\ClassNameCheck;
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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,
],
]);
}

}
Loading
Loading