Skip to content

Commit ba954c3

Browse files
ondrejmirtesclaude
andcommitted
Do not reuse name scopes built from the simplified AST for an analysed file
FileTypeMapper builds a file's name scopes from defaultAnalysisParser, which routes a file outside the analysed paths to the cleaning parser. Its AST has no function bodies, so the name scopes of classes declared inside them are missing. The ftm- cache entry was validated against the file contents only, so after a run on other paths a full run reused the incomplete map and the PHPDoc of such a class resolved to nothing, even with the result cache cleared. The entry now records for each file whether its AST was simplified and is reused only when that still holds, and the key tells the two kinds of entries of the main file apart so that partial and full runs do not evict each other. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fzQU5nUgW3pCEZJonVBhv
1 parent 7d37329 commit ba954c3

7 files changed

Lines changed: 108 additions & 11 deletions

File tree

‎.github/workflows/e2e-tests.yml‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,15 @@ jobs:
255255
- script: |
256256
cd e2e/bug-15231
257257
../../bin/phpstan analyze
258+
- script: |
259+
cd e2e/bug-15475
260+
# https://github.com/phpstan/phpstan/issues/15475
261+
# A run on other paths builds the name scopes of the scanned file from its
262+
# simplified AST, without the anonymous class declared in a function body.
263+
# A later full run must not reuse them, even with the result cache cleared.
264+
../../bin/phpstan analyse src/usage.php
265+
../../bin/phpstan clear-result-cache
266+
../../bin/phpstan analyse
258267
- script: |
259268
cd e2e/bug-15102
260269
composer install
@@ -2181,6 +2190,17 @@ jobs:
21812190
cd e2e/bug-15268
21822191
php ../../bin/phpstan analyse --no-progress --error-format=raw
21832192
2193+
- name: "Verify name scopes built from the simplified AST are not reused for an analysed file"
2194+
# A run on other paths builds the name scopes of the scanned file from its
2195+
# simplified AST, without the anonymous class declared in a function body.
2196+
# A later full run must not reuse them, even with the result cache cleared -
2197+
# https://github.com/phpstan/phpstan/issues/15475
2198+
run: |
2199+
cd e2e/bug-15475
2200+
php ../../bin/phpstan analyse --no-progress src/usage.php
2201+
php ../../bin/phpstan clear-result-cache
2202+
php ../../bin/phpstan analyse --no-progress
2203+
21842204
- name: "Test"
21852205
# The arena is a per-run POSIX shared-memory object: shm_open() plus an
21862206
# ftruncate() to 256 MB that tmpfs honours sparsely, committing pages on

‎e2e/bug-15475/phpstan.neon‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
parameters:
2+
level: max
3+
tmpDir: tmp
4+
# the reproducer declares an anonymous readonly class
5+
phpVersion: 80300
6+
paths:
7+
- src
8+
scanFiles:
9+
- src/helpers.php

‎e2e/bug-15475/src/helpers.php‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
interface Inbox
6+
{
7+
/** @return list<string> */
8+
public function search(string $words): array;
9+
}
10+
11+
/**
12+
* @param Closure(string): list<string> $search
13+
*/
14+
function inbox(Closure $search): Inbox
15+
{
16+
return new readonly class($search) implements Inbox
17+
{
18+
/**
19+
* @param Closure(string): list<string> $search
20+
*/
21+
public function __construct(private Closure $search) {}
22+
23+
public function search(string $words): array
24+
{
25+
return ($this->search)($words);
26+
}
27+
};
28+
}

‎e2e/bug-15475/src/usage.php‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
function usage(): void
6+
{
7+
inbox(static fn (string $words): array => [$words])->search('a b');
8+
}

‎e2e/bug-15475/tmp/.gitignore‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
*
2+
!.*

‎src/Parser/PathRoutingParser.php‎

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,32 @@ public function setAnalysedFiles(array $files): void
4141
}
4242

4343
public function parseFile(string $file): array
44+
{
45+
$parser = $this->getParser($file);
46+
if ($parser === $this->php8Parser) {
47+
return $parser->parseFile($file);
48+
}
49+
50+
return $parser->parseFile($this->fileHelper->normalizePath($file));
51+
}
52+
53+
/**
54+
* Whether parseFile() returns the simplified AST of the file, without function bodies
55+
* and code for other PHP versions, instead of the full AST of an analysed file.
56+
*/
57+
public function returnsSimplifiedAst(string $file): bool
58+
{
59+
return $this->getParser($file) === $this->currentPhpVersionSimpleParser;
60+
}
61+
62+
private function getParser(string $file): Parser
4463
{
4564
$normalizedPath = $this->fileHelper->normalizePath($file, '/');
4665
if (str_contains($normalizedPath, 'vendor/jetbrains/phpstorm-stubs')) {
47-
return $this->php8Parser->parseFile($file);
66+
return $this->php8Parser;
4867
}
4968
if (str_contains($normalizedPath, 'vendor/phpstan/php-8-stubs/stubs')) {
50-
return $this->php8Parser->parseFile($file);
69+
return $this->php8Parser;
5170
}
5271

5372
$file = $this->fileHelper->normalizePath($file);
@@ -64,16 +83,16 @@ public function parseFile(string $file): array
6483
if ($realFilePath !== false) {
6584
$normalizedRealFilePath = $this->fileHelper->normalizePath($realFilePath);
6685
if (isset($this->analysedFiles[$normalizedRealFilePath])) {
67-
return $this->currentPhpVersionRichParser->parseFile($file);
86+
return $this->currentPhpVersionRichParser;
6887
}
6988
}
7089
break;
7190
}
7291

73-
return $this->currentPhpVersionSimpleParser->parseFile($file);
92+
return $this->currentPhpVersionSimpleParser;
7493
}
7594

76-
return $this->currentPhpVersionRichParser->parseFile($file);
95+
return $this->currentPhpVersionRichParser;
7796
}
7897

7998
public function parseString(string $sourceCode): array

‎src/Type/FileTypeMapper.php‎

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use PHPStan\Internal\ComposerHelper;
1919
use PHPStan\Internal\LruCache;
2020
use PHPStan\Parser\Parser;
21+
use PHPStan\Parser\PathRoutingParser;
2122
use PHPStan\PhpDoc\NameScopeAlreadyBeingCreatedException;
2223
use PHPStan\PhpDoc\PhpDocNodeResolver;
2324
use PHPStan\PhpDoc\PhpDocStringResolver;
@@ -383,21 +384,26 @@ private function getNameScopeMap(string $fileName): array
383384
return $cachedEntry;
384385
}
385386

386-
$cacheKey = sprintf('ftm-%s', $fileName);
387+
// a map built from the simplified AST of a file outside the analysed paths lacks
388+
// the name scopes of classes declared inside function bodies, so it must not be
389+
// reused once the file gets analysed, nor the other way around
390+
$cacheKey = sprintf('ftm-%s%s', $this->returnsSimplifiedAst($fileName) ? 'simplified-' : '', $fileName);
387391
// v6: v5 entries may be poisoned by the turbo comment-loss bug
388392
// (https://github.com/phpstan/phpstan/issues/15037) - a parse that
389393
// silently dropped every comment cached PHPDoc-less name-scope maps,
390394
// and the content hashes cannot tell them apart from real ones.
391395
// v7: IntermediaryNameScope refers to a NamespaceUses shared with the other name scopes
392396
// created under the same use statements
393-
$variableCacheKey = sprintf('v7-%s', ComposerHelper::getPhpDocParserVersion());
397+
// v8: the files are recorded with whether the map was built from their simplified AST;
398+
// v7 entries may be built from it even under the key of the full AST
399+
$variableCacheKey = sprintf('v8-%s', ComposerHelper::getPhpDocParserVersion());
394400
$cached = $this->loadCachedPhpDocNodeMap($cacheKey, $variableCacheKey);
395401
if ($cached === null) {
396402
[$nameScopeMap, $files] = $this->createPhpDocNodeMap($fileName, null, null, [], $fileName);
397403
$filesWithHashes = [];
398404
foreach ($files as $file) {
399405
$newHash = $this->fileContentHasher->hash($file);
400-
$filesWithHashes[$file] = $newHash;
406+
$filesWithHashes[$file] = [$newHash, $this->returnsSimplifiedAst($file)];
401407
}
402408
$this->cache->save($cacheKey, $variableCacheKey, [$nameScopeMap, $filesWithHashes]);
403409
} else {
@@ -418,17 +424,17 @@ private function loadCachedPhpDocNodeMap(string $cacheKey, string $variableCache
418424
$cached = $this->cache->load($cacheKey, $variableCacheKey);
419425
if ($cached !== null) {
420426
/**
421-
* @var array<string, string> $filesWithHashes
427+
* @var array<string, array{string, bool}> $filesWithHashes
422428
*/
423429
[$nameScopeMap, $filesWithHashes] = $cached;
424430
$useCache = true;
425-
foreach ($filesWithHashes as $file => $hash) {
431+
foreach ($filesWithHashes as $file => [$hash, $simplifiedAst]) {
426432
$newHash = $this->fileContentHasher->hash($file);
427433
if ($newHash === false) {
428434
$useCache = false;
429435
break;
430436
}
431-
if ($newHash === $hash) {
437+
if ($newHash === $hash && $this->returnsSimplifiedAst($file) === $simplifiedAst) {
432438
continue;
433439
}
434440
$useCache = false;
@@ -443,6 +449,11 @@ private function loadCachedPhpDocNodeMap(string $cacheKey, string $variableCache
443449
return null;
444450
}
445451

452+
private function returnsSimplifiedAst(string $fileName): bool
453+
{
454+
return $this->phpParser instanceof PathRoutingParser && $this->phpParser->returnsSimplifiedAst($fileName);
455+
}
456+
446457
/**
447458
* @param array<string, string> $traitMethodAliases
448459
* @param array<string, true> $activeTraitResolutions

0 commit comments

Comments
 (0)