Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/Application/ApplicationFileProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,11 @@ private function processFile(File $file, Configuration $configuration): FileProc
if ($fileProcessResult->getSystemErrors() !== []) {
$this->changedFilesDetector->invalidateFile($file->getFilePath());
} elseif (! $configuration->isDryRun() || ! $fileProcessResult->getFileDiff() instanceof FileDiff) {
$this->changedFilesDetector->cacheFile($file->getFilePath());
// a file clean under a subset of rules is not necessarily clean under all rules,
// caching it would hide its pending changes from the next full run
if ($configuration->getOnlyRule() === null && $configuration->getOnlySuffix() === null) {
$this->changedFilesDetector->cacheFile($file->getFilePath());
}
}

return $fileProcessResult;
Expand Down
54 changes: 41 additions & 13 deletions src/Caching/Detector/ChangedFilesDetector.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Rector\Caching\Cache;
use Rector\Caching\Config\FileHashComputer;
use Rector\Caching\Enum\CacheKey;
use Rector\Caching\FileDependencyCollector;
use Rector\Util\FileHasher;

/**
Expand All @@ -24,7 +25,8 @@ final class ChangedFilesDetector
public function __construct(
private readonly FileHashComputer $fileHashComputer,
private readonly Cache $cache,
private readonly FileHasher $fileHasher
private readonly FileHasher $fileHasher,
private readonly FileDependencyCollector $fileDependencyCollector
) {
}

Expand All @@ -36,9 +38,26 @@ public function cacheFile(string $filePath): void
return;
}

$hash = $this->hashFile($filePath);
// a failed capture means a possibly incomplete set, skip caching so the file is reprocessed
$dependencyHashes = $this->fileDependencyCollector->getDependencyFileHashes($filePath);
if ($dependencyHashes === null) {
return;
}

$this->cache->save($filePathCacheKey, CacheKey::FILE_HASH_KEY, $hash);
// the file may have just been written, recompute its hash fresh
// rather than trusting a memo entry from the pre-write filter pass
$this->fileDependencyCollector->forgetContentHash($filePath);
$ownHash = $this->fileDependencyCollector->contentHash($filePath);
if ($ownHash === null) {
return;
}

// store the own content hash plus one per dependency, so a dependency change
// invalidates this file even when its own content is unchanged
$this->cache->save($filePathCacheKey, CacheKey::FILE_HASH_KEY, [
'hash' => $ownHash,
'deps' => $dependencyHashes,
]);
}

public function addCacheableFile(string $filePath): void
Expand All @@ -52,13 +71,27 @@ public function hasFileChanged(string $filePath): bool
$fileInfoCacheKey = $this->getFilePathCacheKey($filePath);
$cachedValue = $this->cache->load($fileInfoCacheKey, CacheKey::FILE_HASH_KEY);

if ($cachedValue !== null) {
$currentFileHash = $this->hashFile($filePath);
return $currentFileHash !== $cachedValue;
// no value to compare against → be defensive and assume changed
if ($cachedValue === null) {
return true;
}

// legacy string entry → own-hash comparison only, rewritten in the new format on next cacheFile()
if (is_string($cachedValue)) {
return $this->fileDependencyCollector->contentHash($filePath) !== $cachedValue;
}

// we don't have a value to compare against. Be defensive and assume its changed
return true;
if (! is_array($cachedValue)) {
return true;
}

// own content changed
if (($cachedValue['hash'] ?? null) !== $this->fileDependencyCollector->contentHash($filePath)) {
return true;
}

// any recorded dependency changed
return $this->fileDependencyCollector->hasAnyChangedDependency($cachedValue['deps'] ?? []);
}

public function invalidateFile(string $filePath): void
Expand Down Expand Up @@ -98,11 +131,6 @@ private function getFilePathCacheKey(string $filePath): string
return $this->fileHasher->hash($this->resolvePath($filePath));
}

private function hashFile(string $filePath): string
{
return $this->fileHasher->hashFiles([$this->resolvePath($filePath)]);
}

private function storeConfigurationDataHash(string $filePath, string $configurationHash): void
{
$key = CacheKey::CONFIGURATION_HASH_KEY . '_' . $this->getFilePathCacheKey($filePath);
Expand Down
154 changes: 154 additions & 0 deletions src/Caching/FileDependencyCollector.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
<?php

declare(strict_types=1);

namespace Rector\Caching;

use Rector\Contract\DependencyInjection\ResettableInterface;
use Rector\Util\FileHasher;

/**
* Collects the files each processed file depends on, as surfaced by PHPStan's
* DependencyResolver during scope resolution, and provides memoized content hashes
* for dependency validation by the unchanged-files cache.
*/
final class FileDependencyCollector implements ResettableInterface
{
/**
* @var array<string, array<string, true>>
*/
private array $dependenciesByFile = [];

/**
* files whose capture threw, their possibly partial set must never be cached
*
* @var array<string, true>
*/
private array $failedFiles = [];

/**
* keyed by the given path, so memo hits skip realpath() as well
*
* @var array<string, string|null>
*/
private array $contentHashMemo = [];

/**
* a function's signature dependencies are identical at every call site
*
* @var array<string, string[]>
*/
private array $functionDependencyFilesMemo = [];

public function __construct(
private readonly FileHasher $fileHasher,
) {
}

public function record(string $filePath, string $dependencyFilePath): void
{
if ($filePath === $dependencyFilePath) {
return;
}

$this->dependenciesByFile[$filePath][$dependencyFilePath] = true;
}

public function markFailed(string $filePath): void
{
$this->failedFiles[$filePath] = true;
}

/**
* @return string[]|null
*/
public function getMemoizedFunctionDependencyFiles(string $functionKey): ?array
{
return $this->functionDependencyFilesMemo[$functionKey] ?? null;
}

/**
* @param string[] $dependencyFiles
*/
public function memoizeFunctionDependencyFiles(string $functionKey, array $dependencyFiles): void
{
$this->functionDependencyFilesMemo[$functionKey] = $dependencyFiles;
}

/**
* @return array<string, string>|null null when capture failed and the set cannot be trusted
*/
public function getDependencyFileHashes(string $filePath): ?array
{
if (isset($this->failedFiles[$filePath])) {
return null;
}

$dependencyHashes = [];
foreach (array_keys($this->dependenciesByFile[$filePath] ?? []) as $dependencyFile) {
$dependencyHash = $this->contentHash($dependencyFile);
if ($dependencyHash !== null) {
$dependencyHashes[$dependencyFile] = $dependencyHash;
}
}

return $dependencyHashes;
}

/**
* @param array<string, string> $recordedDependencyHashes
*/
public function hasAnyChangedDependency(array $recordedDependencyHashes): bool
{
foreach ($recordedDependencyHashes as $dependencyFile => $recordedHash) {
if ($this->contentHash($dependencyFile) !== $recordedHash) {
return true;
}
}

return false;
}

/**
* null when the file does not exist, e.g. a deleted dependency, which callers treat as changed
*/
public function contentHash(string $filePath): ?string
{
if (array_key_exists($filePath, $this->contentHashMemo)) {
return $this->contentHashMemo[$filePath];
}

$resolvedPath = $this->resolvePath($filePath);
if (! is_file($resolvedPath)) {
return $this->contentHashMemo[$filePath] = null;
}

return $this->contentHashMemo[$filePath] = $this->fileHasher->hashFiles([$resolvedPath]);
}

/**
* drop a memoized hash, e.g. after the file has been written mid-run
*/
public function forgetContentHash(string $filePath): void
{
unset($this->contentHashMemo[$filePath]);
}

public function reset(): void
{
$this->dependenciesByFile = [];
$this->failedFiles = [];
$this->contentHashMemo = [];
$this->functionDependencyFilesMemo = [];
}

private function resolvePath(string $filePath): string
{
$realPath = realpath($filePath);
if ($realPath === false) {
return $filePath;
}

return $realPath;
}
}
5 changes: 5 additions & 0 deletions src/DependencyInjection/LazyContainerFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use PhpParser\Lexer;
use PHPStan\Analyser\NodeScopeResolver;
use PHPStan\Analyser\ScopeFactory;
use PHPStan\Dependency\DependencyResolver;
use PHPStan\Parser\Parser;
use PHPStan\PhpDoc\TypeNodeResolver;
use PHPStan\PhpDocParser\ParserConfig;
Expand Down Expand Up @@ -38,6 +39,7 @@
use Rector\Caching\CacheFactory;
use Rector\Caching\Config\FileHashComputer;
use Rector\Caching\Contract\CacheMetaExtensionInterface;
use Rector\Caching\FileDependencyCollector;
use Rector\ChangesReporting\Contract\Output\OutputFormatterInterface;
use Rector\ChangesReporting\Output\ConsoleOutputFormatter;
use Rector\ChangesReporting\Output\GitHubOutputFormatter;
Expand Down Expand Up @@ -347,6 +349,7 @@ final class LazyContainerFactory
TypeNodeResolver::class,
NodeScopeResolver::class,
ReflectionProvider::class,
DependencyResolver::class,
];

/**
Expand Down Expand Up @@ -452,6 +455,7 @@ public function create(): RectorConfig

$rectorConfig->singleton(FileProcessor::class);
$rectorConfig->singleton(PostFileProcessor::class);
$rectorConfig->singleton(FileDependencyCollector::class);

$rectorConfig->when(RectorNodeTraverser::class)
->needs('$rectors')
Expand All @@ -476,6 +480,7 @@ static function (Container $container): DynamicSourceLocatorProvider {
// resettable
$rectorConfig->tag(DynamicSourceLocatorProvider::class, ResettableInterface::class);
$rectorConfig->tag(RenamedClassesDataCollector::class, ResettableInterface::class);
$rectorConfig->tag(FileDependencyCollector::class, ResettableInterface::class);

// caching
$rectorConfig->singleton(Cache::class, static function (Container $container): Cache {
Expand Down
42 changes: 42 additions & 0 deletions src/NodeAnalyzer/NativeFunctionCallAnalyzer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);

namespace Rector\NodeAnalyzer;

use PhpParser\Node\Name;

/**
* Whether a called function name can only resolve to a native PHP function.
* Qualified and relative names resolve strictly inside a namespace, use-function
* imports are honored via the resolvedName attribute. Known limitation: an
* unimported userland function shadowing a native name in its own namespace is
* treated as native anyway. Unlike FuncCallNameResolver this stays off
* ReflectionProvider, as it runs per node during scope resolution.
*
* @see \Rector\NodeNameResolver\NodeNameResolver\FuncCallNameResolver
* @see \Rector\Tests\NodeAnalyzer\NativeFunctionCallAnalyzerTest
*/
final class NativeFunctionCallAnalyzer
{
/**
* @var array<string, int>|null
*/
private array|null $internalFunctionNames = null;

public function isNativeFunctionCall(Name $name): bool
Comment thread
TomasVotruba marked this conversation as resolved.
{
if ($name->isQualified() || $name->isRelative()) {
return false;
}

$this->internalFunctionNames ??= array_flip(get_defined_functions()['internal']);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@samsonasik Do you mean this line then? Would static reflection be faster?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I tried this AI generated get_defined_functions() caching usage on StructArmed, it slow, and use of namespaced_name is more than enough there.

If function check exists needed, there is already phpstan reflection function NativeFunctionReflection for that, so way more reliable per static analysis process instead of runtime php.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The table is built once per process and memoized, not per call - measured in rector's container: ~0.05ms to build, 10k checks ≈ 1.4ms. Calling get_defined_functions() per node would indeed be slow; this does not.

The namespaced-name comparison answers a different question: "does this resolve to the global namespace", while this guard needs "can this call have a file dependency". Userland functions in the global namespace - app()/config() in Laravel, any composer files-autoloaded helpers - pass that comparison but live in files the cache must track; skipping capture for them would reintroduce the stale-cache bug this PR fixes. The matrix test pins that global userland functions are not treated as native.

NativeFunctionReflection is only known after resolving through the ReflectionProvider (first getFunction() + variants ≈ 24ms, signature map) - the work this guard sits in front of. And the runtime list errs only in the safe direction: a name not in it simply is not skipped, capture proceeds and PHPStan stays authoritative.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

That means the laravelapp() is not properly loaded by phpstan, to make it properly loaded, you may probably needs to load via withBootstrapFiles(), eg:

<?php

use Rector\Config\RectorConfig;

return RectorConfig::configure()
    ->withBootstrapFiles([__DIR__ . '/bootstrap/app.php'];

The reason to use exactly PHPStan reflection as possible is to avoid runtime which inverse with what static analysis purpose.


$resolvedName = $name->getAttribute('resolvedName');
if ($resolvedName instanceof Name) {
return isset($this->internalFunctionNames[$resolvedName->toLowerString()]);
}

return isset($this->internalFunctionNames[$name->toLowerString()]);
}
}
Loading
Loading