Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
Track the files of a package installed from a path repository
A path repository points Composer at a directory of the project itself, and
Composer symlinks or copies it into vendor/. Either way the package is code
the developer edits, and the edits never reach composer.lock: the recorded
version and reference stay exactly as they were, so the package-level
invalidation from #5933 never fires for it. Symlinked,
the installed package is the source directory and an edit is visible to the
next analysis immediately; copied, it takes a reinstall, and Composer
reinstalls without moving the reference. In both cases the result cache kept
serving results for code that had changed.

The files of such a package are now recorded as file dependencies as well as
under the package, so the tracking added for the project's other non-analysed
files applies to them: an edit inside a method body re-analyses nothing, and
a changed signature re-analyses the files depending on it. Being in both
places is deliberate - a genuine version change still seeds the dependents
through the package entry.

installed.php cannot tell the two apart, a path package looks there exactly
like a downloaded one, so the dist type comes from installed.json next to it.
A package that is missing from installed.json, or whose dist type is anything
else, keeps the previous behaviour: editing a file of an installed package is
not a valid thing to do, the next Composer command overwrites it, and PHPStan
goes on trusting the cache.

e2e/result-cache-path-repository covers both installation modes next to an
ordinary dependency: updating psr/log from 3.0.1 to 3.0.2 re-analyses the one
file using it; editing a file inside psr/log changes nothing; for both the
symlinked and the copied package a body-only edit changes nothing and a
changed return type re-analyses the file using it. Editing the directory a
copied package was copied from is also asserted to change nothing - the
analysis reads the copy.
  • Loading branch information
ondrejmirtes committed Sep 3, 2026
commit ed73837831c7325ba7b6b272aa004a03188f189b
57 changes: 57 additions & 0 deletions .github/workflows/e2e-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -843,6 +843,63 @@ jobs:
echo "$OUTPUT"
../bashunit -a contains 'Composer packages changed (test/logger); re-analysing only the files depending on them.' "$OUTPUT"
../bashunit -a contains 'Result cache restored. 1 file will be reanalysed.' "$OUTPUT"
- script: |
cd e2e/result-cache-path-repository
composer install
../../bin/phpstan analyse
# Updating an installed package re-analyses only the files depending on it.
patch -p0 < updatePsrLog.patch
composer update psr/log --no-cache
OUTPUT=$(../bashunit -a exit_code "0" "../../bin/phpstan analyse -vv")
echo "$OUTPUT"
../bashunit -a contains 'Composer packages changed (psr/log); re-analysing only the files depending on them.' "$OUTPUT"
../bashunit -a contains 'Result cache restored. 1 file will be reanalysed.' "$OUTPUT"
# Editing a file of an installed package is not a valid thing to do - the next Composer
# command overwrites it - so PHPStan does not track it and keeps the cached results, even
# though this edit takes away a method the analysed code calls.
php -r '$f = "vendor/psr/log/src/LoggerInterface.php"; file_put_contents($f, str_replace("public function info(", "public function renamedInfo(", file_get_contents($f)));'
OUTPUT=$(../bashunit -a exit_code "0" "../../bin/phpstan analyse -vv")
echo "$OUTPUT"
../bashunit -a contains 'Result cache restored. 0 files will be reanalysed.' "$OUTPUT"
../bashunit -a not_contains 'Call to an undefined method' "$OUTPUT"
# A package from a path repository is the opposite case: it is the project's own code,
# edited in place without any Composer command noticing, so its files are tracked like the
# project's other non-analysed files. Symlinked, the installed package is the source
# directory - an edit inside a method body changes nothing the files using it can see.
patch -b -p0 < symlinked-body.patch
OUTPUT=$(../bashunit -a exit_code "0" "../../bin/phpstan analyse -vv")
echo "$OUTPUT"
../bashunit -a contains 'Result cache restored. 0 files will be reanalysed.' "$OUTPUT"
# A changed return type does reach them.
mv symlinked-lib/src/SymlinkedDep.php.orig symlinked-lib/src/SymlinkedDep.php
patch -b -p0 < symlinked-signature.patch
OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw")
echo "$OUTPUT"
../bashunit -a contains 'Result cache restored. 1 file will be reanalysed.' "$OUTPUT"
../bashunit -a contains 'UsesSymlinked.php:16:Method ResultCacheE2EPathRepository\UsesSymlinked::doUsesSymlinked() should return int but returns string.' "$OUTPUT"
# Undoing it clears the error again.
mv symlinked-lib/src/SymlinkedDep.php.orig symlinked-lib/src/SymlinkedDep.php
OUTPUT=$(../bashunit -a exit_code "0" "../../bin/phpstan analyse -vv")
echo "$OUTPUT"
../bashunit -a contains 'Result cache restored. 1 file will be reanalysed.' "$OUTPUT"
# Copied instead of symlinked, the analysis reads the installed copy, so editing the
# directory Composer copied from is not something PHPStan can see at all.
patch -b -p0 < copied-source-signature.patch
OUTPUT=$(../bashunit -a exit_code "0" "../../bin/phpstan analyse -vv")
echo "$OUTPUT"
../bashunit -a contains 'Result cache restored. 0 files will be reanalysed.' "$OUTPUT"
mv copied-lib/src/CopiedDep.php.orig copied-lib/src/CopiedDep.php
# The installed copy is tracked the same way the symlinked package is.
patch -b -p0 < copied-body.patch
OUTPUT=$(../bashunit -a exit_code "0" "../../bin/phpstan analyse -vv")
echo "$OUTPUT"
../bashunit -a contains 'Result cache restored. 0 files will be reanalysed.' "$OUTPUT"
mv vendor/test/copied-lib/src/CopiedDep.php.orig vendor/test/copied-lib/src/CopiedDep.php
patch -b -p0 < copied-signature.patch
OUTPUT=$(../bashunit -a exit_code "1" "../../bin/phpstan analyse -vv --error-format raw")
echo "$OUTPUT"
../bashunit -a contains 'Result cache restored. 1 file will be reanalysed.' "$OUTPUT"
../bashunit -a contains 'UsesCopied.php:16:Method ResultCacheE2EPathRepository\UsesCopied::doUsesCopied() should return int but returns string.' "$OUTPUT"
- script: |
cd e2e/result-cache-extension-update
composer install
Expand Down
2 changes: 2 additions & 0 deletions e2e/result-cache-path-repository/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/vendor
/composer.lock
24 changes: 24 additions & 0 deletions e2e/result-cache-path-repository/composer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "phpstan/result-cache-path-repository-e2e",
"repositories": [
{
"type": "path",
"url": "./symlinked-lib"
},
{
"type": "path",
"url": "./copied-lib",
"options": {
"symlink": false
}
}
],
"require": {
"psr/log": "3.0.1",
"test/symlinked-lib": "*",
"test/copied-lib": "*"
},
"autoload": {
"classmap": ["src"]
}
}
13 changes: 13 additions & 0 deletions e2e/result-cache-path-repository/copied-body.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
--- vendor/test/copied-lib/src/CopiedDep.php
+++ vendor/test/copied-lib/src/CopiedDep.php
@@ -7,7 +7,9 @@

public function doDep(): int
{
- return 1;
+ $result = 1;
+
+ return $result;
}

}
1 change: 1 addition & 0 deletions e2e/result-cache-path-repository/copied-lib/composer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "name": "test/copied-lib", "version": "1.0.0", "autoload": { "classmap": ["src"] } }
13 changes: 13 additions & 0 deletions e2e/result-cache-path-repository/copied-lib/src/CopiedDep.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

namespace Test\CopiedLib;

class CopiedDep
{

public function doDep(): int
{
return 1;
}

}
14 changes: 14 additions & 0 deletions e2e/result-cache-path-repository/copied-signature.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
--- vendor/test/copied-lib/src/CopiedDep.php
+++ vendor/test/copied-lib/src/CopiedDep.php
@@ -5,9 +5,9 @@
class CopiedDep
{

- public function doDep(): int
+ public function doDep(): string
{
- return 1;
+ return 'foo';
}

}
14 changes: 14 additions & 0 deletions e2e/result-cache-path-repository/copied-source-signature.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
--- copied-lib/src/CopiedDep.php
+++ copied-lib/src/CopiedDep.php
@@ -5,9 +5,9 @@
class CopiedDep
{

- public function doDep(): int
+ public function doDep(): string
{
- return 1;
+ return 'foo';
}

}
5 changes: 5 additions & 0 deletions e2e/result-cache-path-repository/phpstan.neon
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
parameters:
level: 8
tmpDir: tmp
paths:
- src
19 changes: 19 additions & 0 deletions e2e/result-cache-path-repository/src/UsesCopied.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

namespace ResultCacheE2EPathRepository;

use Test\CopiedLib\CopiedDep;

class UsesCopied
{

public function __construct(private CopiedDep $dep)
{
}

public function doUsesCopied(): int
{
return $this->dep->doDep();
}

}
19 changes: 19 additions & 0 deletions e2e/result-cache-path-repository/src/UsesPsrLog.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

namespace ResultCacheE2EPathRepository;

use Psr\Log\LoggerInterface;

class UsesPsrLog
{

public function __construct(private LoggerInterface $logger)
{
}

public function doUsesPsrLog(): void
{
$this->logger->info('hello');
}

}
19 changes: 19 additions & 0 deletions e2e/result-cache-path-repository/src/UsesSymlinked.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

namespace ResultCacheE2EPathRepository;

use Test\SymlinkedLib\SymlinkedDep;

class UsesSymlinked
{

public function __construct(private SymlinkedDep $dep)
{
}

public function doUsesSymlinked(): int
{
return $this->dep->doDep();
}

}
13 changes: 13 additions & 0 deletions e2e/result-cache-path-repository/symlinked-body.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
--- symlinked-lib/src/SymlinkedDep.php
+++ symlinked-lib/src/SymlinkedDep.php
@@ -7,7 +7,9 @@

public function doDep(): int
{
- return 1;
+ $result = 1;
+
+ return $result;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "name": "test/symlinked-lib", "version": "1.0.0", "autoload": { "classmap": ["src"] } }
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

namespace Test\SymlinkedLib;

class SymlinkedDep
{

public function doDep(): int
{
return 1;
}

}
14 changes: 14 additions & 0 deletions e2e/result-cache-path-repository/symlinked-signature.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
--- symlinked-lib/src/SymlinkedDep.php
+++ symlinked-lib/src/SymlinkedDep.php
@@ -5,9 +5,9 @@
class SymlinkedDep
{

- public function doDep(): int
+ public function doDep(): string
{
- return 1;
+ return 'foo';
}

}
2 changes: 2 additions & 0 deletions e2e/result-cache-path-repository/tmp/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
*
!.*
11 changes: 11 additions & 0 deletions e2e/result-cache-path-repository/updatePsrLog.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
--- composer.json
+++ composer.json
@@ -14,7 +14,7 @@
}
],
"require": {
- "psr/log": "3.0.1",
+ "psr/log": "3.0.2",
"test/symlinked-lib": "*",
"test/copied-lib": "*"
},
13 changes: 11 additions & 2 deletions src/Dependency/NodeDependencies.php
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ public function getFileDependencies(string $currentFile, array $analysedFiles):
* - "files": the remaining project files - listed in scanFiles/scanDirectories, excluded from the
* analysis but living in an analysed directory, or simply reached through the autoloader -
* recorded as regular file dependencies, so that editing one of them re-analyses only the files
* depending on it instead of invalidating the whole result cache.
* depending on it instead of invalidating the whole result cache. A package installed from a
* path repository is in both: it is the project's own code, edited without Composer noticing.
*
* Files inside a PHAR belong to the running PHPStan itself and cannot change without its version
* changing, so they are left out of both.
Expand Down Expand Up @@ -110,7 +111,15 @@ public function getNonAnalysedDependencies(string $currentFile, array $analysedF
$package = $packageDependencyResolver->resolvePackage($dependencyFile);
if ($package !== null) {
$packages[$package] = $package;
continue;

// A package installed from a path repository is the project's own code: Composer
// symlinked or copied it out of a directory next to the project, and it is edited in
// place without its recorded version or reference moving. Tracking the file as well as
// the package is what notices those edits - the package entry alone only reacts to a
// composer.lock change.
if (!$packageDependencyResolver->isPathPackage($package)) {
continue;
}
}

$files[$dependencyFile] = $dependencyFile;
Expand Down
Loading