🌐 US-Proxy
class="logged-out env-production page-responsive" style="word-wrap: break-word;" >
Skip to content

Track file dependencies for global constant fetches - #6023

Merged
staabm merged 4 commits into
phpstan:2.2.xfrom
SanderMuller:const-dependency-tracking
Jul 11, 2026
Merged

Track file dependencies for global constant fetches#6023
staabm merged 4 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>
Comment thread src/Dependency/DependencyResolver.php
@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.

Comment thread tests/PHPStan/Analyser/AnalyserTest.php Outdated
The e2e test in the previous commit exercises the full result-cache flow,
so the AnalyserTest case that asserted the raw dependency edge is redundant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@SanderMuller
SanderMuller requested a review from staabm July 9, 2026 08:02
namespace ResultCacheE2EConstants;

-const SOME_MODE = 1;
+const RENAMED_MODE = 1;

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.

Do we have the same problem when a const is declared using define()?

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.

Good question, and yes, define() has the same problem. This PR doesn't fix it. Repro:

// define.php
define('SOME_DEFINED_MODE', 1);
// consumer.php
namespace App;
function usesDefinedMode(): int { return SOME_DEFINED_MODE; }

Analyse (cache warm), change the define() value to 'str', analyse again: no error (stale). A cold run correctly reports usesDefinedMode() should return int but returns string.

The reason is that a define() is a function call, not a declaration, so it produces no exported node. The result cache re-analyses a changed file's dependents only when the file's exported nodes change, so the value change isn't detected even though the reading file depends on the define()'s file.

Handling it means exporting define() calls that have literal arguments (name + value), reusing the ExportedConstantNode from this PR. WordPress-style code leans on define(), so it's probably worth doing. Happy to add it here, or as a follow-up once this lands. Which do you prefer?

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.

Lets do it in a separate PR

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.

Opened it as #6036 (stacked on this one, so I'll rebase to a clean diff once this lands).

Comment thread src/Dependency/DependencyResolver.php Outdated
Comment on lines +374 to +376
$lowercasedConstantName !== 'true'
&& $lowercasedConstantName !== 'false'
&& $lowercasedConstantName !== 'null'

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.

usually we use in_array for such case

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.

Done, switched to in_array($constantName->toLowerString(), ['true', 'false', 'null'], true).


public function getMode(): int
{
return SOME_MODE;

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 think we should have another test which shows, that changing a constants value (not the name), will trigger new errors - e.g.

	/**
	 * @return int<1, 3>
	 */
	public function getMode(): int
	{
		return SOME_MODE;
	}

when SOME_MODE define is changed to 5, it should start erroring

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.

Added. The e2e now also changes SOME_MODE from 1 to 5 with a @return int<1, 3> consumer, and asserts it starts erroring on the warm run (should return int<1, 3> but returns 5), alongside the existing rename test.

Address review: replace the true/false/null comparison chain with in_array,
and extend the result-cache e2e to also cover a constant value change (not
just a rename) via an int<1, 3> return type that starts erroring when the
constant becomes 5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@staabm
staabm merged commit a6b87aa into phpstan:2.2.x Jul 11, 2026
676 checks passed
@staabm

staabm commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Thank you

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.

4 participants