From d4a3091c46b47001eb600314c977be6d8a36bb13 Mon Sep 17 00:00:00 2001 From: dualfroz Date: Sat, 17 Oct 2026 03:31:37 +0200 Subject: [PATCH] Refresh abandoned state from the repositories in audit The abandoned flag in composer.lock and installed.json is only written when a package gets updated, so composer audit never reported packages that were abandoned after that. The audit command now loads the audited versions from the repositories, first repository with the package name wins, and syncs the abandoned state and replacement from there. This is skipped with --abandoned=ignore and unreachable repositories are skipped with --ignore-unreachable like for advisories. --- src/Composer/Command/AuditCommand.php | 59 +++++++++++++++++++ .../Test/Command/AuditCommandTest.php | 21 +++++++ 2 files changed, 80 insertions(+) diff --git a/src/Composer/Command/AuditCommand.php b/src/Composer/Command/AuditCommand.php index f7327fb..14c9cad 100644 --- a/src/Composer/Command/AuditCommand.php +++ b/src/Composer/Command/AuditCommand.php @@ -13,6 +13,7 @@ namespace Composer\Command; use Composer\Composer; +use Composer\Downloader\TransportException; use Composer\FilterList\FilterListProvider\FilterListProviderSet; use Composer\Policy\ListPolicyConfig; use Composer\Policy\PolicyConfig; @@ -20,10 +21,14 @@ use Composer\Repository\RepositorySet; use Composer\Repository\RepositoryUtils; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +use Composer\Package\AliasPackage; +use Composer\Package\BasePackage; +use Composer\Package\CompletePackageInterface; use Composer\Package\PackageInterface; use Composer\Repository\InstalledRepository; use Composer\Advisory\Auditor; use Composer\Console\Input\InputOption; +use Composer\Semver\Constraint\Constraint; class AuditCommand extends BaseCommand { @@ -96,6 +101,10 @@ EOT $policyConfig = $policyConfig->withIgnoreUnreachable('audit'); } + if ($policyConfig->abandoned->audit !== ListPolicyConfig::AUDIT_IGNORE) { + $this->refreshAbandonedState($composer, $packages, $policyConfig->ignoreUnreachable->audit); + } + $filterListProviderSet = $policyConfig->enabled ? FilterListProviderSet::create($policyConfig, $composer->getRepositoryManager()->getRepositories(), $composer->getLoop()->getHttpDownloader()) : null; return min(255, $auditor->audit( @@ -133,4 +142,54 @@ EOT return $installedRepo->getPackages(); } + + /** + * The lock file and installed.json keep the abandoned state a package had when it was last updated, + * so it is refreshed from the repositories in case the package was abandoned or unabandoned since + * + * @param PackageInterface[] $packages + */ + private function refreshAbandonedState(Composer $composer, array $packages, bool $ignoreUnreachable): void + { + $packagesByName = []; + $packageNameMap = []; + $stabilityFlags = []; + foreach ($packages as $package) { + if (!$package instanceof CompletePackageInterface || $package instanceof AliasPackage) { + continue; + } + + $packagesByName[$package->getName()] = $package; + $packageNameMap[$package->getName()] = new Constraint('==', $package->getVersion()); + // per package stability so stable versions do not need to load the ~dev metadata + $stabilityFlags[$package->getName()] = BasePackage::STABILITIES[$package->getStability()]; + } + + foreach ($composer->getRepositoryManager()->getRepositories() as $repository) { + if ($packageNameMap === []) { + break; + } + + try { + $result = $repository->loadPackages($packageNameMap, [], $stabilityFlags); + } catch (TransportException $e) { + if (!$ignoreUnreachable) { + throw $e; + } + + continue; + } + + foreach ($result['packages'] as $repoPackage) { + if ($repoPackage instanceof CompletePackageInterface && !$repoPackage instanceof AliasPackage) { + $packagesByName[$repoPackage->getName()]->setAbandoned($repoPackage->isAbandoned() ? ($repoPackage->getReplacementPackage() ?? true) : false); + } + } + + foreach ($result['namesFound'] as $name) { + // like RepositorySet::findPackages, do not query lower priority repositories for names already found + unset($packageNameMap[$name]); + } + } + } } diff --git a/tests/Composer/Test/Command/AuditCommandTest.php b/tests/Composer/Test/Command/AuditCommandTest.php index 9aa5262..603b50b 100644 --- a/tests/Composer/Test/Command/AuditCommandTest.php +++ b/tests/Composer/Test/Command/AuditCommandTest.php @@ -146,6 +146,27 @@ class AuditCommandTest extends TestCase self::assertSame(Auditor::STATUS_OK, $exitCode); } + public function testAuditReportsPackageAbandonedAfterItWasLocked(): void + { + $this->initTempComposer([ + 'repositories' => [ + 'packages' => [ + 'type' => 'package', + 'package' => [ + ['name' => 'vendor/abandoned', 'version' => '1.0.0', 'abandoned' => 'vendor/replacement'], + ], + ], + ], + ]); + $this->createComposerLockWithPackages([['name' => 'vendor/abandoned', 'version' => '1.0.0']]); + + $appTester = $this->getApplicationTester(); + $exitCode = $appTester->run(['command' => 'audit', '--format' => 'plain']); + + self::assertStringContainsString('vendor/abandoned is abandoned. Use vendor/replacement instead.', $appTester->getDisplay(true)); + self::assertSame(Auditor::STATUS_FAILED, $exitCode); + } + /** * @param list $packages */