Skip to content

Track file dependencies for global constant fetches#6023

Open
SanderMuller wants to merge 2 commits into
phpstan:2.2.xfrom
SanderMuller:const-dependency-tracking
Open

Track file dependencies for global constant fetches#6023
SanderMuller wants to merge 2 commits into
phpstan:2.2.xfrom
SanderMuller:const-dependency-tracking

Conversation

@SanderMuller

@SanderMuller SanderMuller commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

DependencyResolver recorded file dependencies for class constants, functions, methods and properties, but not for global/namespaced constant fetches (Node\Expr\ConstFetch). On top of that, global constant declarations were not exported nodes. The result cache re-analyses a changed file's dependents only when the changed file's exported nodes change, so a file that reads a global constant was never re-analysed when that constant changed, leaving a stale result.

This fixes both halves:

  1. Record a dependency on the file declaring a fetched global constant (ConstFetch in DependencyResolver), guarding the ubiquitous true/false/null literals so the common path stays a couple of string comparisons.
  2. Export global constant declarations (name + value) as RootExportedNodes, like class constants already are, so renaming or changing a constant re-analyses the files that use it.

Two ways to hit the stale result, both fixed and covered by the result-cache-constants e2e test:

  • editing a global constant's declaration; the files reading it are re-analysed on the next run;
  • bumping a Composer package that declares the constant; with package-granular cache invalidation the reading file is now among the re-seeded files.

Performance

The ConstFetch branch runs for every constant fetch, so it is guarded. Measured over a cold, single-process analysis:

corpus level ConstFetch nodes skipped (true/false/null) getConstant() calls added time
phpstan-src 8 10,151 9,928 223 4.9 ms
a doctrine/symfony app 5 12,279 11,348 930 16.5 ms
laravel/laravel 5 8,151 7,676 474 9.6 ms

Added time is under 0.02% of analysis CPU, and that figure is an upper bound (it includes the measurement probes). The exported-node part runs only for global const declarations, which are rare.

Reproducing the staleness

// a.php
<?php declare(strict_types = 1);
namespace App;
const MODE = 1;
// b.php
<?php declare(strict_types = 1);
namespace App;
function f(): int { return MODE; }
vendor/bin/phpstan analyse a.php b.php   # no errors; cache warm
# change a.php to: const MODE = 'x';
vendor/bin/phpstan analyse a.php b.php   # before: stale, still no error

Before this change the second run keeps b.php cached and reports nothing; a cold run reports that f() returns string. With the change the second run re-analyses b.php and matches the cold run.

DependencyResolver recorded dependencies for class constants, functions,
methods and properties, but not for global/namespaced constant fetches
(Node\Expr\ConstFetch). A file that used only a global constant declared
elsewhere therefore recorded no dependency on it, so changing the constant
did not re-analyse the consumer from the result cache, leaving a stale
result (also surfaced by package-granular cache invalidation when the
declaring package is bumped). Resolve the constant and record its declaring
file, guarding the ubiquitous true/false/null literals so the common path
stays cheap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
}
}
}
} elseif ($node instanceof Node\Expr\ConstFetch) {

@staabm staabm Jul 8, 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.

changes in DependencyResolver are usually covered with a e2e tests, see e.g. e5db864

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.

Adding the e2e test actually surfaced a gap, working on it!

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.

Thanks, good catch. I added e2e/result-cache-constants modelled on result-cache-traits: it analyses, renames a global constant via a patch, and asserts the file reading it re-analyses and reports the constant as undefined.

Writing the e2e surfaced that the dependency edge alone wasn't enough: global constant declarations weren't exported nodes either, so a constant change never invalidated the reading files (the result cache re-analyses a changed file's dependents only when its exported nodes change). I pushed a second commit exporting global constants (name + value), like class constants already are. With both, the e2e fails before the change and passes after.

@SanderMuller SanderMuller marked this pull request as draft July 8, 2026 13:06
The dependency edge added in the previous commit records that a file reads a
global constant, but the result cache only re-analyses a changed file's
dependents when that file's exported nodes change, and global constant
declarations were not exported nodes. So editing a global constant still left
the reading files with stale cached results. Export global constant
declarations (name + value) like class constants already are, so renaming or
changing a constant re-analyses the files that use it. Covered by a
result-cache e2e test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@SanderMuller SanderMuller marked this pull request as ready for review July 8, 2026 13:41
@phpstan-bot

Copy link
Copy Markdown
Collaborator

This pull request has been marked as ready for review.

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.

3 participants