Skip to content

Fix --only runs caching files as unchanged, hiding pending changes from full runs#8029

Merged
TomasVotruba merged 3 commits into
rectorphp:mainfrom
SanderMuller:fix/only-rule-cache-poisoning
Jun 19, 2026
Merged

Fix --only runs caching files as unchanged, hiding pending changes from full runs#8029
TomasVotruba merged 3 commits into
rectorphp:mainfrom
SanderMuller:fix/only-rule-cache-poisoning

Conversation

@SanderMuller

@SanderMuller SanderMuller commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Problem

A file that is clean under one rule is not necessarily clean under all rules. A run with --only SomeRule (or --only-suffix) caches such files as unchanged, so the next full run silently skips their pending changes until --clear-cache.

Repro on main:

  1. rector process --dry-run --clear-cache --only "Rector\DeadCode\Rector\ClassMethod\RemoveEmptyClassMethodRector" on a file with an always-true if → clean under that rule, file gets cached
  2. rector process --dry-run → reports [OK] Rector is done! instead of the pending RemoveAlwaysTrueIfConditionRector change

Fix

Skip the unchanged-files cache write on selective runs. One guard in ApplicationFileProcessor.

Test

Unit test on ApplicationFileProcessor::processFiles(): an --only / --only-suffix run must leave the file uncached, a plain dry-run caches it (control test). The two guard tests fail on main, pass with the fix.

Split out of #8028 as a standalone pre-existing bugfix, as discussed with @TomasVotruba.

🤖 Generated with Claude Code

A file clean under one rule is not necessarily clean under all rules. An
--only run cached such files as unchanged, so the next full run silently
skipped their pending changes until --clear-cache.

Covered by a new e2e scenario that fails on main: an --only run followed
by a full run must still report the change pending under the other rule.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SanderMuller added a commit to SanderMuller/rector-src that referenced this pull request Jun 10, 2026
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>
@TomasVotruba

Copy link
Copy Markdown
Member

Thanks for the fix 👍

e2e seems heavy for such a verfication.
Is there any way to test this via unit test? Should be some simple operation to check.

ApplicationFileProcessor::processFiles() with no rules registered reaches
the cache-write branch directly, so the guard is testable without an e2e
project: an --only / --only-suffix run must leave the file uncached, a
plain dry-run caches it. Fails on main, passes with the fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@SanderMuller

Copy link
Copy Markdown
Contributor Author

Good call — replaced the e2e with a unit test, PR is now 81 lines total.

ApplicationFileProcessor::processFiles() with no rules registered reaches the cache-write branch directly, so no e2e project is needed: an --only / --only-suffix run must leave the file uncached, a plain dry-run caches it (control test). Verified the two guard tests fail on main and pass with the fix. Runs in ~0.6s.

Comment thread tests/Application/ApplicationFileProcessor/Source/CleanFile.php
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SanderMuller added a commit to SanderMuller/rector-src that referenced this pull request Jun 11, 2026
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, the same engine
behind PHPStan's result cache. 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.

Function calls memoize their dependency files per resolved name, as
signature dependencies are identical at every call site.

Selective runs (--only, --only-suffix) bypass the cache write, same
guard as rectorphp#8029.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SanderMuller added a commit to SanderMuller/rector-src that referenced this pull request Jun 11, 2026
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, the same engine
behind PHPStan's result cache. 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.

Function calls memoize their dependency files per resolved name, as
signature dependencies are identical at every call site.

Selective runs (--only, --only-suffix) bypass the cache write, same
guard as rectorphp#8029.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@TomasVotruba TomasVotruba requested a review from samsonasik June 19, 2026 10:29
@TomasVotruba

Copy link
Copy Markdown
Member

I'd love to merge this one. @samsonasik Can you check if its ready?

@samsonasik

samsonasik commented Jun 19, 2026

Copy link
Copy Markdown
Member

@TomasVotruba seems ok, the drawback is with this change, the --only and --only-suffix will not have cache on run same command multiple times

@TomasVotruba

TomasVotruba commented Jun 19, 2026

Copy link
Copy Markdown
Member

@samsonasik Hm, that would be a bit counter productive. But cost of having full cache cleared on single --only use is a bigger drawback. If I understand this change correctly.

@samsonasik

Copy link
Copy Markdown
Member

Yeah, I tried to handle this in the past, but it not works well as expected, as the cache hit will back and forth, see old PR:

@TomasVotruba

Copy link
Copy Markdown
Member

In that case, I'll go with this approach, seems more intuitive.

@TomasVotruba TomasVotruba merged commit b31a1e5 into rectorphp:main Jun 19, 2026
64 checks passed
@TomasVotruba

Copy link
Copy Markdown
Member

Thank you @SanderMuller 👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants