Skip to content

Track scanned files as file dependencies instead of invalidating the whole result cache - #6331

Open
phpstan-bot wants to merge 3 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-8atqs1d
Open

Track scanned files as file dependencies instead of invalidating the whole result cache#6331
phpstan-bot wants to merge 3 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-8atqs1d

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

Editing a single scanned file (scanFiles, scanDirectories, or a file excluded from the analysis but living in an analysed directory) invalidated the entire result cache, because the SHA-256 hashes of all scanned files are part of the cache metadata and any metadata difference meant a full re-analysis. In a monorepo where several PHPStan configs scan each other's code, every edit made all the other caches useless.

Scanned files are now tracked the same way third-party packages are (#5933): an analysed file records which non-analysed project files it depends on, and editing one of them re-analyses only the files depending on it.

Changes

  • src/Dependency/NodeDependencies.php: getPackageDependencies() is replaced by getNonAnalysedDependencies(), which walks the reflections once and splits the dependency files that getFileDependencies() drops into packages (files of an installed Composer package, resolved to the package name - the previous behaviour) and files (the remaining project files, by path). Files inside a PHAR are left out of both: they belong to the running PHPStan, whose version is already part of the metadata.
  • src/Analyser/FileAnalyserCallback.php: the files half is appended to the file dependencies (and to the used-trait dependencies for resolveUsedTraitDependencies()), so the cached dependency graph gains entries for scanned files. Merging the two calls into one keeps the per-node work at the same two traversals as before (measured on this repo's self-analysis: 73.0s before, 71.6s after).
  • src/Analyser/ResultCache/ResultCacheManager.php:
    • PARTIALLY_INVALIDATING_META_KEYS (composerLocks, composerInstalled, scannedFiles) generalizes the existing Composer-only special case; a difference limited to those keys no longer forces a full re-analysis, and a run where both changed is handled by both mechanisms.
    • getChangedScannedFiles() diffs the cached and the current scannedFiles metadata into "changed file => still scanned?".
    • A changed scanned file that is not tracked as a file dependency - one of an installed Composer package, or inside a PHAR - still triggers a full re-analysis, naming the file in the reason.
    • The deletedFiles loop is now the notAnalysedFiles loop: an entry that is not analysed but still on disk keeps its edges in the graph for the next run, and its dependent files (plus used-trait dependent files) are re-analysed only when its hash differs from the cached one. A missing file keeps the old "deleted file" behaviour.
    • A scanned file that appeared or was edited also re-analyses the files that currently have errors, exactly like a new analysed file does, so an error about a symbol the scanned file now defines is not left behind.
    • Cache version bumped to v15-scannedFileDependencies.
  • .github/workflows/e2e-tests.yml plus e2e/result-cache-scanned-2, e2e/result-cache-scanned-trait, e2e/result-cache-scanned-vendor and e2e/result-cache-autoloaded: new and updated end-to-end tests (below).

Root cause

The result cache tracked scanned files only as a hash map inside the metadata, an all-or-nothing signal: restore() compared the whole metadata array and returned a full analysis on any difference. There was no per-file link between an analysed file and the scanned file it depends on, because NodeDependencies::getFileDependencies() deliberately dropped every dependency file that is not analysed.

The same gap had a second, quieter symptom: a project file that is neither analysed nor scanned - only reachable through the Composer autoloader (an autoload-dev namespace, a directory outside paths) - was not tracked anywhere at all, so editing it re-analysed nothing and the cache silently kept stale results. Recording those files as ordinary file dependencies fixes both: the pinpointed invalidation for scanned files, and the missing invalidation for the rest.

Test

E2E tests, in the shape of the existing result-cache tests (patch a file, observe what the cache does):

  • e2e/result-cache-scanned (updated): editing a file excluded from the analysis but living in an analysed directory now restores the cache and re-analyses 1 file, instead of reporting metadata do not match: scannedFiles. The reported error is unchanged.
  • e2e/result-cache-scanned-2 (new): a project with both scanFiles and scanDirectories and three analysed files. Editing the scanned class re-analyses only the file depending on it; undoing the edit clears the error again; editing the scanFiles entry re-analyses only its dependent; deleting the scanned file re-analyses its dependent and reports the unknown class.
  • e2e/result-cache-scanned-trait (new): a trait declared in a scanned file - covers the used-trait dependency path.
  • e2e/result-cache-autoloaded (new): a project file that is neither analysed nor scanned, reachable only through the Composer autoloader. Before this change the run re-analysed 0 files and reported no error at all.
  • e2e/result-cache-scanned-vendor (new): a scanned directory inside an installed Composer package - asserts the full-invalidation fallback still kicks in, so this case cannot go stale.

Also probed and found to work through the same mechanism (no separate test kept, they share the single code path): functions and constants declared in a scanned file, and a scanned file being re-added after deletion.

make tests (21198 tests), make phpstan, make cs-fix and all 72 result-cache E2E scripts pass. e2e/result-cache-relative-path fails locally only because it checks out HEAD into a git worktree, which does not contain the uncommitted change; it passes once the change is committed.

Fixes phpstan/phpstan#15149

@ondrejmirtes

Copy link
Copy Markdown
Member

/cc @SanderMuller @staabm Would be nice if you could test this and review this. Thanks.

@SanderMuller SanderMuller left a comment

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.

Tested and reviewed on 42e6a75c2. It does what it says and I could not break it. The merge call is yours, but from my side this looks good.

What I verified

All five e2e fixtures pass on the PR head. The more useful direction: I kept the PR's e2e fixtures and reverted only the three src/ files to bdb4580dd, and four of them fail. result-cache-autoloaded fails in the way that matters, reporting Result cache restored. 0 files will be reanalysed. and no error at all, which is the silent staleness you describe.

Probes beyond the fixtures, all on the PR head:

  • Two-hop inheritance through scanned files (analysed A, scanned B extends C, edit C): 1 file re-analysed, error caught. The graph records the grandparent.
  • Adding a new scanned file that defines a previously unknown class: 1 file re-analysed and the class.notFound errors clear, so the $scannedFileAddedOrEdited path does what it claims.
  • A global constant and a function declared in a scanned file: both dependents re-analysed, both errors caught. That covers the two paths you probed but kept no test for.
  • Editing a scanned file and running again with nothing further changed settles to 0 files will be reanalysed, so the hash written back for a not-analysed file is the fresh one rather than the cached one.

Gate on the PR head: full suite 21249 tests / 96329 assertions green, self-analysis clean, phpcs clean on the three touched files. None of the three classes carries #[ShadowedByTurboExtension] and turbo-ext/ does not reference them, so there is no native mirror to keep in sync.

Performance

A 4524-file corpus at level 5, cold, base against PR, 2 interleaved rounds:

  • CPU flat: 137.6s and 138.8s against 136.6s and 139.5s.
  • resultCache.php grows from 39.76MB to 40.88MB, +2.8%. The same size to the byte in both rounds, so that one is a real cost rather than noise. Whole tmpDir 194MB to 195MB.

I would treat +2.8% as a floor rather than a typical figure. On that corpus nearly every non-analysed dependency resolves to an installed Composer package and lands in the packages bucket, so few new file edges get recorded. A monorepo scanning sibling project code, which is the case the issue describes, will record considerably more.

The red CI

None of it reproduces here.

  • The six extension and integration reds (doctrine, Larastan, Rector, Symplify, phpstan-laravel, symplify/phpstan-rules) are the same set #6328 carries.
  • Tests with old PHPUnit also fails on #6332, #6330 and #6326. The three failures are IntersectionTypeTest::testIsAcceptedBy, Maybe against No, which references none of the classes touched here and passes locally, 106/106.
  • Test (PHP 7.4) is the phpbench gate against the committed baseline. Its @Assert expression only asserts on time, so the red mem_peak cells in that table are diff colouring rather than assertion failures. I A/B'd base against PR locally on the three flagged variants: nullsafe-chain-walk came out at 166.578mb against 166.577mb, and on the other two the PR never used more than base. Time stayed inside run-to-run noise. That runner also reported rstdev diffs up to +471%, which is the usual tell.

What I did not check

Everything above is PHP 8.5 on macOS. I did not reproduce the downgraded 7.4 source that the red bench job actually runs, and I tested neither Windows nor a run with the turbo extension loaded.

@staabm staabm left a comment

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.

What about scanDirectories and bootstrapFiles NEON configs?

@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Those were leftover polling tasks for the test run that already finished — no new information, and I've stopped the remaining ones.

The work stands as reported: make tests 21198 tests / 96239 assertions green, make phpstan clean, and both commits (04834c6f5, e9b6bc876) pushed to create-pull-request/patch-8atqs1d.

ondrejmirtes and others added 3 commits September 1, 2026 20:52
…whole result cache

- `NodeDependencies::getNonAnalysedDependencies()` replaces `getPackageDependencies()`: one pass over the reflections returns both the Composer packages of the non-analysed dependency files and the remaining project files (scanFiles/scanDirectories, files excluded from the analysis, files only reachable through the autoloader). Files inside a PHAR are left out - they belong to the running PHPStan.
- `FileAnalyserCallback` records those project files as regular file dependencies (and as used-trait dependencies for traits), so the cached dependency graph knows which analysed files depend on which scanned file.
- `ResultCacheManager::restore()` no longer throws the cache away when only the `scannedFiles` metadata differs: the graph entries of files that are not analysed but still on disk are carried over, their dependents (including used-trait dependents) are re-analysed only when the file's hash changed, and a scanned file that appeared or was edited also re-analyses the files with errors, the same way a new analysed file does.
- A changed scanned file that `getNonAnalysedDependencies()` does not track - one belonging to an installed Composer package (tracked at package granularity) or living inside a PHAR - still falls back to a full re-analysis, so no stale results are kept.
- The `composerLocks`/`composerInstalled` handling is generalized into `PARTIALLY_INVALIDATING_META_KEYS`, so a run where both the Composer metadata and the scanned files changed is handled incrementally by both mechanisms.
- Bumped the result cache version to `v15-scannedFileDependencies`.
- Same fix applies to the analogous cases: `scanFiles`, `scanDirectories`, files excluded from the analysis inside an analysed directory, traits and functions/constants declared in scanned files, deleted and re-added scanned files, and project files reachable only through the Composer autoloader (previously not tracked at all, which left stale results behind).
The scanned file fixtures exercise editing and deleting a scanned file, but not
one appearing: a new file in a scanDirectories directory may define a symbol
that is reported as unknown somewhere, which is what the $scannedFileAddedOrEdited
branch re-analyses the files with errors for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A bootstrapFile is executed, not just read, so a change in one of them can
affect anything about the analysis. They stay in the fully invalidating
executedFilesHashes metadata entry rather than being tracked per file like the
scanned ones - say so where PARTIALLY_INVALIDATING_META_KEYS is declared, and
cover it with an e2e test so it cannot regress unnoticed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@staabm
staabm force-pushed the create-pull-request/patch-8atqs1d branch from e9b6bc8 to 5fc697b Compare September 1, 2026 18:52
@staabm

staabm commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

needs rebase

continue;
}

if (str_starts_with($dependencyFile, 'phar://')) {

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.

I guess this is a perf optimization because phar's are readonly and cannot change?
if so, should have a comment

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.

would it also make sense for .phar-ending paths?

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Better result cache for scanned paths

4 participants