Repository: symfony/flex (php)

Issue:
[Bug] Flex behaves differently depending if package is present in vendor/ or not
If you have a package missing in your symfony.lock, but present in composer.lock, Flex will:

- run the recipe if package is not already in `vendor/`
- not run it if it's there

Basically, if the package is being downloaded, it will trigger, but not if the package is not downloaded by Composer.

Pull request description:
Fix detecting new packages before installing their recipes
Fix #851

Tracking the transactional changeset from composer.lock leads to the linked issue.
Instead, this tracks the changeset by computing it from symfony.lock.

Requires the `InstallerEvents::PRE_OPERATIONS_EXEC` event, which is Composer 2-only.

Patch:
diff --git a/src/Flex.php b/src/Flex.php
index 1c00d1f..12defd8 100644
--- a/src/Flex.php
+++ b/src/Flex.php
@@ -19,6 +19,7 @@ use Composer\DependencyResolver\Operation\OperationInterface;
 use Composer\DependencyResolver\Operation\UninstallOperation;
 use Composer\DependencyResolver\Operation\UpdateOperation;
 use Composer\DependencyResolver\Pool;
+use Composer\DependencyResolver\Transaction;
 use Composer\Downloader\FileDownloader;
 use Composer\EventDispatcher\EventSubscriberInterface;
 use Composer\Factory;
@@ -35,6 +36,7 @@ use Composer\Json\JsonManipulator;
 use Composer\Package\BasePackage;
 use Composer\Package\Comparer\Comparer;
 use Composer\Package\Locker;
+use Composer\Package\Package;
 use Composer\Package\PackageInterface;
 use Composer\Plugin\PluginEvents;
 use Composer\Plugin\PluginInterface;
@@ -45,6 +47,7 @@ use Composer\Repository\RepositoryFactory;
 use Composer\Repository\RepositoryManager;
 use Composer\Script\Event;
 use Composer\Script\ScriptEvents;
+use Composer\Semver\VersionParser;
 use Symfony\Component\Console\Input\ArgvInput;
 use Symfony\Component\Filesystem\Filesystem;
 use Symfony\Flex\Event\UpdateEvent;
@@ -341,11 +344,34 @@ class Flex implements PluginInterface, EventSubscriberInterface
 
     public function record(PackageEvent $event)
     {
-        if ($this->shouldRecordOperation($event)) {
+        if ($this->shouldRecordOperation($event->getOperation(), $event->isDevMode(), $event->getComposer())) {
             $this->operations[] = $event->getOperation();
         }
     }
 
+    public function recordOperations(InstallerEvent $event)
+    {
+        if (!$event->isExecutingOperations()) {
+            return;
+        }
+
+        $versionParser = new VersionParser();
+        $packages = [];
+        foreach ($this->lock->all() as $name => $info) {
+            $packages[] = new Package($name, $versionParser->normalize($info['version']), $info['version']);
+        }
+
+        $transation = \Closure::bind(function () use ($packages, $event) {
+            return new Transaction($packages, $event->getTransaction()->resultPackageMap);
+        }, null, Transaction::class)();
+
+        foreach ($transation->getOperations() as $operation) {
+            if ($this->shouldRecordOperation($operation, $event->isDevMode(), $event->getComposer())) {
+                $this->operations[] = $operation;
+            }
+        }
+    }
+
     public function update(Event $event, $operations = [])
     {
         if ($operations) {
@@ -854,9 +880,12 @@ class Flex implements PluginInterface, EventSubscriberInterface
         return sprintf('<info>%s</> (<comment>>=%s</>): From %s', $matches[1], $matches[2], 'auto-generated recipe' === $matches[3] ? '<comment>'.$matches[3].'</>' : $matches[3]);
     }
 
-    private function shouldRecordOperation(PackageEvent $event): bool
+    private function shouldRecordOperation(OperationInterface $operation, bool $isDevMode, Composer $composer = null): bool
     {
-        $operation = $event->getOperation();
+        if ($this->dryRun) {
+            return false;
+        }
+
         if ($operation instanceof UpdateOperation) {
             $package = $operation->getTargetPackage();
         } else {
@@ -864,8 +893,8 @@ class Flex implements PluginInterface, EventSubscriberInterface
         }
 
         // when Composer runs with --no-dev, ignore uninstall operations on packages from require-dev
-        if (!$event->isDevMode() && $operation instanceof UninstallOperation) {
-            foreach ($event->getComposer()->getLocker()->getLockData()['packages-dev'] as $p) {
+        if (!$isDevMode && $operation instanceof UninstallOperation) {
+            foreach (($composer ?? $this->composer)->getLocker()->getLockData()['packages-dev'] as $p) {
                 if ($package->getName() === $p['name']) {
                     return false;
                 }
@@ -993,9 +1022,6 @@ class Flex implements PluginInterface, EventSubscriberInterface
         }
 
         $events = [
-            PackageEvents::POST_PACKAGE_INSTALL => 'record',
-            PackageEvents::POST_PACKAGE_UPDATE => [['record'], ['enableThanksReminder']],
-            PackageEvents::POST_PACKAGE_UNINSTALL => 'record',
             ScriptEvents::POST_CREATE_PROJECT_CMD => 'configureProject',
             ScriptEvents::POST_INSTALL_CMD => 'install',
             ScriptEvents::PRE_UPDATE_CMD => 'configureInstaller',
@@ -1005,6 +1031,9 @@ class Flex implements PluginInterface, EventSubscriberInterface
 
         if (version_compare('2.0.0', PluginInterface::PLUGIN_API_VERSION, '>')) {
             $events += [
+                PackageEvents::POST_PACKAGE_INSTALL => 'record',
+                PackageEvents::POST_PACKAGE_UPDATE => [['record'], ['enableThanksReminder']],
+                PackageEvents::POST_PACKAGE_UNINSTALL => 'record',
                 InstallerEvents::PRE_DEPENDENCIES_SOLVING => [['populateProvidersCacheDir', \PHP_INT_MAX]],
                 InstallerEvents::POST_DEPENDENCIES_SOLVING => [['populateFilesCacheDir', \PHP_INT_MAX]],
                 PackageEvents::PRE_PACKAGE_INSTALL => [['populateFilesCacheDir', ~\PHP_INT_MAX]],
@@ -1012,7 +1041,11 @@ class Flex implements PluginInterface, EventSubscriberInterface
                 PluginEvents::PRE_FILE_DOWNLOAD => 'onFileDownload',
             ];
         } else {
-            $events += [PluginEvents::PRE_POOL_CREATE => 'truncatePackages'];
+            $events += [
+                PackageEvents::POST_PACKAGE_UPDATE => 'enableThanksReminder',
+                InstallerEvents::PRE_OPERATIONS_EXEC => 'recordOperations',
+                PluginEvents::PRE_POOL_CREATE => 'truncatePackages',
+            ];
         }
 
         return $events;


Test patch:
diff --git a/tests/Command/DumpEnvCommandTest.php b/tests/Command/DumpEnvCommandTest.php
index 0127455..0d0e277 100644
--- a/tests/Command/DumpEnvCommandTest.php
+++ b/tests/Command/DumpEnvCommandTest.php
@@ -12,8 +12,9 @@
 namespace Symfony\Flex\Tests\Command;
 
 use Composer\Config;
+use Composer\Console\Application;
+use Composer\IO\NullIO;
 use PHPUnit\Framework\TestCase;
-use Symfony\Component\Console\Application;
 use Symfony\Component\Console\Tester\CommandTester;
 use Symfony\Flex\Command\DumpEnvCommand;
 use Symfony\Flex\Options;
@@ -210,6 +211,11 @@ EOF
         );
 
         $application = new Application();
+
+        \Closure::bind(function () {
+            $this->io = new NullIO();
+        }, $application, $application)();
+
         $application->add($command);
         $command = $application->find('dump-env');
 
diff --git a/tests/Command/UpdateRecipesCommandTest.php b/tests/Command/UpdateRecipesCommandTest.php
index 8b940f9..1f09cf9 100644
--- a/tests/Command/UpdateRecipesCommandTest.php
+++ b/tests/Command/UpdateRecipesCommandTest.php
@@ -11,12 +11,12 @@
 
 namespace Symfony\Flex\Tests\Command;
 
+use Composer\Console\Application;
 use Composer\Factory;
 use Composer\IO\BufferIO;
 use Composer\Plugin\PluginInterface;
 use Composer\Util\Platform;
 use PHPUnit\Framework\TestCase;
-use Symfony\Component\Console\Application;
 use Symfony\Component\Console\Tester\CommandTester;
 use Symfony\Component\Filesystem\Filesystem;
 use Symfony\Component\Process\Process;
diff --git a/tests/FlexTest.php b/tests/FlexTest.php
index 751fcf4..9894771 100644
--- a/tests/FlexTest.php
+++ b/tests/FlexTest.php
@@ -364,6 +364,7 @@ EOF
     {
         $event = $this->getMockBuilder(PackageEvent::class, ['getOperation'])->disableOriginalConstructor()->getMock();
         $event->expects($this->any())->method('getOperation')->willReturn(new InstallOperation($package));
+        $event->expects($this->any())->method('isDevMode')->willReturn(true);
 
         return $event;
     }