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.
This commit is contained in:
dualfroz committed 2026-10-17 03:31:37 +02:00
1 parent 12931dd03f
commit d4a3091c46
2 files changed
+80

No files matched your search

+59
View File
@@ -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]);
}
}
}
}
@@ -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<array{name: string, version: string}> $packages
*/