Fix unchanged-files cache missing changes induced by changed dependencies#8028
Fix unchanged-files cache missing changes induced by changed dependencies#8028SanderMuller wants to merge 1 commit into
Conversation
| return false; | ||
| } | ||
|
|
||
| $this->internalFunctionNames ??= array_flip(get_defined_functions()['internal']); |
There was a problem hiding this comment.
@samsonasik Do you mean this line then? Would static reflection be faster?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
The cache only checked each file's own content, so a clean file stayed skipped on warm runs even when one of its dependencies changed, e.g. a parent class method gaining a return type that lets a child file infer its own. A fresh run reports the new change, a warm run misses it. PHPStanNodeScopeResolver now records each file's dependencies during scope resolution using PHPStan's own DependencyResolver. Cache entries store the file's own hash plus one hash per dependency, all re-validated on load; legacy string entries self-upgrade on the next write. A failed capture skips caching entirely rather than caching a partial set. To keep capture cheap, only nodes whose resolver branch can produce dependencies are resolved, and native function calls are skipped via NativeFunctionCallAnalyzer, avoiding PHPStan's function signature map load. Two tripwire tests pin the external assumptions: one parses the bundled DependencyResolver and fails if its branch set changes on a PHPStan bump, the other asserts native function reflections stay fileless. Selective runs (--only, --only-suffix) bypass the cache write, same guard as rectorphp#8029. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
0960cea to
2347f37
Compare
|
Slimmed this down as discussed: it now contains only the dependency-aware unchanged-files cache fix — 626 lines, half of which is tests. The @samsonasik your |
Problem
The unchanged-files cache only checks each file's own content. A clean file stays skipped on warm runs even when one of its dependencies changed.
Repro on
main: a parent class method gains: int, which lets a child file infer its own return type. A fresh run reports the new change in the child file, a warm run reports nothing until--clear-cache.The warm run reprocesses
Dad.php(own hash changed) but skipsKid.php(own hash unchanged), so the newKid::value(): intsuggestion never appears. Reproducible with vanilla rector: cache a clean state, apply theDad.phpedit, run again — theKid.phpchange only shows up after--clear-cache.Fix
PHPStanNodeScopeResolverrecords which files each file depends on during scope resolution, using PHPStan's ownDependencyResolver(the engine behind PHPStan's result cache). The set is transitive: an inheritance chain records the grandparent.Cache entries store the file's own hash plus one hash per dependency, all re-validated on load. Old string entries still load and upgrade themselves on the next write, so existing caches keep working. If capture fails for a file, the file is not cached at all rather than cached with a partial set.
To keep capture cheap, only node types whose resolver branch can actually produce dependencies get resolved. The biggest skip: native function calls, whose first resolution makes PHPStan load its entire function signature map (
NativeFunctionCallAnalyzer, with a mutation-verified matrix test over every name form). Two tripwire tests pin the assumptions that live outside this repo: one parses the bundledDependencyResolverand fails if its branch set changes on a PHPStan bump, the other asserts native function reflections stay fileless.Cost: ~6% on a cold run of a 4,400-file project; warm runs unchanged within noise. Output stays byte-identical to a fresh run.
Gist
One capture call per dependency-relevant node during scope resolution rector already does:
And the cache entry goes from a bare content hash to the hash plus the captured dependency hashes, all re-validated on load:
Scope
Slimmed down from the original PR as discussed — this now contains only the correctness fix. Split out / follow-ups:
--onlyruns caching files as clean (standalone, already open)Known limitation, same gap the existing cache already has: a newly added file that changes name resolution for an unchanged file is not detected, since no dependency edge existed yet.
--clear-cachecovers it, same contract as today.