Skip to content

Result cache: value and file dependencies declared on the scope - #6638

Merged
ondrejmirtes merged 5 commits into
2.3.xfrom
value-dependency
Sep 30, 2026
Merged

ondrejmirtes merged 5 commits into
2.3.xfrom
value-dependency

Conversation

@ondrejmirtes

@ondrejmirtes ondrejmirtes commented Sep 30, 2026 •

Copy link
Copy Markdown
Member

Rules and extensions can now tell the result cache what they read besides the analysed code, so that only the files depending on it are analysed again when it changes. Before, the only option was a ResultCacheMetaExtension, which invalidates the whole result cache.

Closes phpstan/phpstan#15144
Closes phpstan/phpstan#4548
Closes phpstan/phpstan#11510

Related: phpstan/phpstan-symfony#455 (the phpstan-symfony side builds on this)

$scope->valueDependency()

A rule, a dynamic return type extension or an expression type resolver extension typehints the scope as Scope&DependencyEmitter in the PHPDoc and declares what it asks about:

/**
 * @param Scope&DependencyEmitter $scope
 */
public function processNode(Node $node, Scope $scope): array
{
	$scope->valueDependency(ServiceValueExtension::class, $serviceId);
	$service = $this->serviceMap->getService($serviceId);
	// ...
}

ServiceValueExtension implements the new ResultCacheValueExtension (tag phpstan.resultCacheValueExtension). It returns the current value for a key, and converts the key for storing in the result cache and back, e.g. to make paths relative. The value is stored in the result cache. On the next run it is asked for again, and when it's different, the files that declared it are analysed again.

  • The same value declared by more rules and extensions in one file is recorded once.
  • Usually the file analysed at the time is the one depending on the value. When the value is declared outside the walk of the analysed file - PHPStan infers the type of a private property from the constructor on a scope of the class's file, and remembers it for the files analysed later - it's the class's file and the files depending on it.
  • When the extension is no longer registered, its dependents are analysed again and the value is forgotten.

$scope->fileDependency()

A shortcut through FileResultCacheValueExtension for a file read without PHPStan knowing about it. The value is the hash of the file, or that it does not exist, so creating, changing or deleting it analyses the dependent files again. A dependency of the analysed file on itself is dropped.

RequireFileExistsRule uses it now. RuleErrorBuilder::fileDependency() is deprecated - it can declare a dependency only along with an error - and its paths are declared on the scope of the rule's node.

static::FOO_* and T::FOO_*

The first commit makes static::*, static::FOO_*, T::* and T::class in PHPDocs resolve once the class is known, instead of reporting an unresolvable type. It's reported only when static or T is certainly a final class without such constants.

  • Where the class isn't known yet - static in a class that is not final, a template type, an interface or an abstract class - a subclass can declare more constants matching a wildcard, so only the native type is certain.
  • Once static or T is a concrete class - the class a method is called on, the class being instantiated, or the class-string passed for T - it's its constants, the same way a single static::FOO already resolves for the class a method is called on.
  • static in the PHPDoc of an inherited constructor is the class being instantiated.

Tests

  • NSRT, IncompatiblePhpDocTypeRule, IncompatiblePropertyPhpDocTypeRule, InstantiationRule and CallToFunctionParametersRule tests for Resolve template with value of BackEnum #4548 and #11510.
  • e2e/result-cache-value-dependency - services and parameters in a JSON "container", asked about by a rule and dynamic return type extensions. Checks filesToAnalyseCount of result-cache-info --json after changing a value somebody asked about, one nobody asked about, a missing one, one asked about by two files, one used in the inferred type of a private property, and one asked about in a trait - the file of the class using it is analysed again.
  • e2e/result-cache-file-dependency - a rule and dynamic return type extensions of a function, a method and a static method reading files; created, edited and deleted files; the private property case; the deprecated RuleErrorBuilder::fileDependency(). A file of a class whose method declares the dependency is analysed again, the files depending on that class are not.

simple-downgrader 2.2.9 downgrades any intersection of Scope with interfaces from PHPStan\Analyser to Scope.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GdwtgiEHwkF5BTz73jgHQQ

@ondrejmirtes ondrejmirtes left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it work properly when analysing inside traits?

*/
public function doService(string $type): void
{
assertType("'getService'|'hasService'", $type);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is wrong, subclass might declare more constants, this assertion would only be okay in a final class


public function doFoo(string $type): string
{
assertType("'container-1'|'getService'|'hasService'|'parameter'", $type);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is wrong, subclass might declare more constants, this assertion would only be okay in a final class

ondrejmirtes and others added 4 commits September 30, 2026 22:35
A class constant wildcard on static or on a template type was resolved
against the class known at the declaration - an interface declaring none of
the constants its implementations do, the bound of the template - and was
unresolvable there. Both now resolve once the class is known: static for the
class the method is called on, a template once it is resolved. Only a final
class, or a template bound to one, is known not to declare more constants
than it does now, and only for those an unmatched wildcard is unresolvable.

Until then the PHPDoc type cannot be compared with the native type, so it is
kept rather than replaced by it, and not reported as incompatible with it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdwtgiEHwkF5BTz73jgHQQ
…pendency()

A rule or a dynamic return type extension that reads something the result
cache does not know about - a DI container, a config file - declares it with
$scope->valueDependency(SomeValueExtension::class, $key). The extension, a
ResultCacheValueExtension, returns the value for the key. The value is stored
in the result cache, and when it's different on the next run, the files that
asked for it are re-analysed.

The same value declared more times, by one or several rules and extensions,
is recorded once. When it's declared outside the walk of the analysed file,
like when a private property type is inferred from the constructor, it is the
file of the scope that depends on it, together with the files depending on
that file.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdwtgiEHwkF5BTz73jgHQQ
$scope->fileDependency($path) declares that the analysis of the current file
depends on the contents of another file - a data file, a template, a
docblock in a PHP file - that is read without PHPStan knowing about it. It is
a value dependency through FileResultCacheValueExtension: the value is the
hash of the file, or that the file does not exist, so creating, changing or
deleting it re-analyses the files that declared it. The path is stored
relative to the same directory as the other paths in the result cache.

A dependency of the analysed file on itself is dropped, the file is
re-analysed when it changes anyway.

RequireFileExistsRule declares the paths on the scope now, and the paths
from the deprecated RuleErrorBuilder::fileDependency() are declared on the
scope of the rule's node.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdwtgiEHwkF5BTz73jgHQQ
@ondrejmirtes
ondrejmirtes merged commit 223a01f into 2.3.x Sep 30, 2026
523 of 525 checks passed
@ondrejmirtes
ondrejmirtes deleted the value-dependency branch September 30, 2026 20:44
@SanderMuller

Copy link
Copy Markdown
Contributor

One thing I found after the merge. It is the cause of the new make phpstan red in phpstan-doctrine.

In the phar, DynamicMethodReturnTypeExtension, DynamicStaticMethodReturnTypeExtension, DynamicFunctionReturnTypeExtension and ExpressionTypeResolverExtension are downgraded to a native Scope plus @param \PHPStan\Analyser\Scope&\PHPStan\Analyser\DependencyEmitter $scope. An implementation that declares a plain Scope and has no PHPDoc of its own inherits that @param. So a direct call with a plain Scope now fails analysis. That is what an extension's own test does with a mock. phpstan-doctrine's make phpstan shows it:

Parameter #3 $scope of method PHPStan\Type\Doctrine\DoctrineSelectableDynamicReturnTypeExtension::getTypeFromMethodCall() expects PHPStan\Analyser\DependencyEmitter&PHPStan\Analyser\Scope, PHPStan\Analyser\Scope&PHPUnit\Framework\MockObject\MockObject given.

I reproduced it with a minimal extension and a PHPUnit test that passes $this->createMock(Scope::class), at level 5:

  • 2.3.x-dev phar 702fde2: no errors
  • 2.3.x-dev phar 223a01f: the same argument.type error

A source checkout does not behave like this. I checked it with stand-in interfaces. An implementation declaring Base does not inherit a native Base&Extra parameter, but it does inherit a native Base with @param Base&Extra. So an extension suite that passes against the source fails against the phar. Rule::processNode() already had the intersection before, so rule tests are not new. But the message got longer, and an ignore pattern written for the old one no longer matches: bitExpert/phpstan-magento's integration job fails on that.

Other checks, which found nothing:

  • Traits: the e2e case for LoggerTrait covers it (the class using the trait is re-analysed, the error is in the trait).
  • The two static::* assertions you flagged are string now.
  • Performance on Tempest, source checkouts f30b81c77 against 223a01f3b, interleaved. Cold: 5.926 s against 5.859 s, CPU 36.2 s against 36.0 s (3 rounds). Warm: 0.652 s against 0.654 s (8 rounds). The result cache grew by 64 bytes, and the 2816 errors are identical.
  • MutatingScope gains only the two methods, so there is no per-node cost.

The Integration tests (ubuntu-latest) red, testWarmAnalysisDoesNotRewriteUnchangedResultCache, is not from this PR. Locally, a warm run of that two-file project rewrote the cache in 5 of 12 runs on f30b81c77 and 6 of 12 on 223a01f3b. The dibi and nette make phpstan reds come from phpstan/phpstan-phpunit#325, merged today, which now discourages assertEmpty(). The rest are codeberg returning 503 for phpstan-dba, and nette 8.6 not resolving its dependencies.

@staabm

staabm commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

There is a new release of https://github.com/ondrejmirtes/simple-downgrader which looks like related and might fix your finding

@SanderMuller

Copy link
Copy Markdown
Contributor

Thanks, but that release is the one this PR already uses. 2.2.9 (b12a15de3, 2026-09-30) is the newest tag, compiler/composer.lock on 2.3.x pins it, and the 223a01f phar I reproduced with was built with it.

It is also what writes the inherited @param. Its own test for the visitor expects a native \PHPStan\Analyser\Scope $scope plus @param \PHPStan\Analyser\Scope&\PHPStan\Analyser\NodeCallbackInvoker&\PHPStan\Analyser\CollectedDataEmitter&\PHPStan\Analyser\DependencyEmitter $scope. An implementation without its own PHPDoc inherits that @param, and that is what narrows it.

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

Labels

None yet

Projects

None yet

3 participants