8000 feature #51849 [AssetMapper] Warn of missing or incompat dependencies… · symfony/symfony@071a971 · GitHub
[go: up one dir, main page]

Skip to content

Commit 071a971

Browse files
feature #51849 [AssetMapper] Warn of missing or incompat dependencies (weaverryan)
This PR was squashed before being merged into the 6.4 branch. Discussion ---------- [AssetMapper] Warn of missing or incompat dependencies | Q | A | ------------- | --- | Branch? | 6.4 | Bug fix? | no | New feature? | yes | Deprecations? | no | Tickets | None | License | MIT Hi! The aim of the `importmap.php` system is to be a simple way to manage your JS dependencies. But to make it robust enough for production use, it does need a few things - like the `importmap:audit` command in #51650. This PR adds a check, during `importmap:require` and `importmap:update`, that reports any missing dependencies or dependencies with invalid versions. This is necessary so that, if package `A` requires package `B`, their versions don't "drift" over time without you being aware (e.g. you update package `A` to v3 but keep package `B` at v1, even though v3 of `A` requires v2 of `B`). <img width="1266" alt="Screenshot 2023-10-04 at 2 44 04 PM" src="https://github.com/symfony/symfony/assets/121003/3901a070-d092-494a-a7cb-3bfe5d5a99f9"> Built on top of #51786. Cheers! Commits ------- 42dfb9a [AssetMapper] Warn of missing or incompat dependencies
2 parents f5178e0 + 42dfb9a commit 071a971

15 files changed

+795
-37
lines changed

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"require": {
3636
"php": ">=8.1",
3737
"composer-runtime-api": ">=2.1",
38+
"composer/semver": "^3.0",
3839
"ext-xml": "*",
3940
"friendsofphp/proxy-manager-lts": "^1.0.2",
4041
"doctrine/event-manager": "^1.2|^2",

src/Symfony/Bundle/FrameworkBundle/Resources/config/asset_mapper.php

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
use Symfony\Component\AssetMapper\ImportMap\ImportMapManager;
3535
use Symfony\Component\AssetMapper\ImportMap\ImportMapRenderer;
3636
use Symfony\Component\AssetMapper\ImportMap\ImportMapUpdateChecker;
37+
use Symfony\Component\AssetMapper\ImportMap\ImportMapVersionChecker;
3738
use Symfony\Component\AssetMapper\ImportMap\RemotePackageDownloader;
3839
use Symfony\Component\AssetMapper\ImportMap\RemotePackageStorage;
3940
use Symfony\Component\AssetMapper\ImportMap\Resolver\JsDelivrEsmResolver;
@@ -171,6 1E79 +172,12 @@
171172
service('asset_mapper.importmap.resolver'),
172173
])
173174

175+
->set('asset_mapper.importmap.version_checker', ImportMapVersionChecker::class)
176+
->args([
177+
service('asset_mapper.importmap.config_reader'),
178+
service('asset_mapper.importmap.remote_package_downloader'),
179+
])
180+
174181
->set('asset_mapper.importmap.resolver', JsDelivrEsmResolver::class)
175182
->args([service('http_client')])
176183

@@ -199,6 +206,7 @@
199206
->args([
200207
service('asset_mapper.importmap.manager'),
201208
param('kernel.project_dir'),
209+
service('asset_mapper.importmap.version_checker'),
202210
])
203211
->tag('console.command')
204212

@@ -207,7 +215,10 @@
207215
->tag('console.command')
208216

209217
->set('asset_mapper.importmap.command.update', ImportMapUpdateCommand::class)
210-
->args([service('asset_mapper.importmap.manager')])
218+
->args([
219+
service('asset_mapper.importmap.manager'),
220+
service('asset_mapper.importmap.version_checker'),
221+
])
211222
->tag('console.command')
212223

213224
->set('asset_mapper.importmap.command.install', ImportMapInstallCommand::class)

src/Symfony/Component/AssetMapper/Command/ImportMapRequireCommand.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
use Symfony\Component\AssetMapper\ImportMap\ImportMapEntry;
1515
use Symfony\Component\AssetMapper\ImportMap\ImportMapManager;
16+
use Symfony\Component\AssetMapper\ImportMap\ImportMapVersionChecker;
1617
use Symfony\Component\AssetMapper\ImportMap\PackageRequireOptions;
1718
use Symfony\Component\Console\Attribute\AsCommand;
1819
use Symfony\Component\Console\Command\Command;
@@ -28,9 +29,12 @@
2829
#[AsCommand(name: 'importmap:require', description: 'Require JavaScript packages')]
2930
final class ImportMapRequireCommand extends Command
3031
{
32+
use VersionProblemCommandTrait;
33+
3134
public function __construct(
3235
private readonly ImportMapManager $importMapManager,
3336
private readonly string $projectDir,
37+
private readonly ImportMapVersionChecker $importMapVersionChecker,
3438
) {
3539
parent::__construct();
3640
}
@@ -108,6 +112,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int
108112
}
109113

110114
$newPackages = $this->importMapManager->require($packages);
115+
116+
$this->renderVersionProblems($this->importMapVersionChecker, $output);
117+
111118
if (1 === \count($newPackages)) {
112119
$newPackage = $newPackages[0];
113120
$message = sprintf('Package "%s" added to importmap.php', $newPackage->importName);

src/Symfony/Component/AssetMapper/Command/ImportMapUpdateCommand.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
use Symfony\Component\AssetMapper\ImportMap\ImportMapEntry;
1515
use Symfony\Component\AssetMapper\ImportMap\ImportMapManager;
16+
use Symfony\Component\AssetMapper\ImportMap\ImportMapVersionChecker;
1617
use Symfony\Component\Console\Attribute\AsCommand;
1718
use Symfony\Component\Console\Command\Command;
1819
use Symfony\Component\Console\Input\InputArgument;
@@ -26,8 +27,11 @@
2627
#[AsCommand(name: 'importmap:update', description: 'Update JavaScript packages to their latest versions')]
2728
final class ImportMapUpdateCommand extends Command
2829
{
30+
use VersionProblemCommandTrait;
31+
2932
public function __construct(
30-
protected readonly ImportMapManager $importMapManager,
33+
private readonly ImportMapManager $importMapManager,
34+
private readonly ImportMapVersionChecker $importMapVersionChecker,
3135
) {
3236
parent::__construct();
3337
}
@@ -57,6 +61,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int
5761
$io = new SymfonyStyle($input, $output);
5862
$updatedPackages = $this->importMapManager->update($packages);
5963

64+
$this->renderVersionProblems($this->importMapVersionChecker, $output);
65+
6066
if (0 < \count($packages)) {
6167
$io->success(sprintf(
6268
'Updated %s package%s in importmap.php.',
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\AssetMapper\Command;
13+
14+
use Symfony\Component\AssetMapper\ImportMap\ImportMapVersionChecker;
15+
use Symfony\Component\Console\Output\OutputInterface;
16+
17+
/**
18+
* @internal
19+
*/
20+
trait VersionProblemCommandTrait
21+
{
22+
private function renderVersionProblems(ImportMapVersionChecker $importMapVersionChecker, OutputInterface $output): void
23+
{
24+
$problems = $importMapVersionChecker->checkVersions();
25+
foreach ($problems as $problem) {
26+
if (null === $problem->installedVersion) {
27+
$output->writeln(sprintf('[warning] <info>%s</info> requires <info>%s</info> but it is not in the importmap.php. You may need to run "php bin/console importmap:require %s".', $problem->packageName, $problem->dependencyPackageName, $problem->dependencyPackageName));
28+
29+
continue;
30+
}
31+
32+
if (null === $problem->requiredVersionConstraint) {
33+
$output->writeln(sprintf('[warning] <info>%s</info> appears to import <info>%s</info> but this is not listed as a dependency of <info>%s</info>. This is odd and could be a misconfiguration of that package.', $problem->packageName, $problem->dependencyPackageName, $problem->packageName));
34+
35+
continue;
36+
}
37+
38+
$output->writeln(sprintf('[warning] <info>%s</info> requires <info>%s</info>@<comment>%s</comment> but version <comment>%s</comment> is installed.', $problem->packageName, $problem->dependencyPackageName, $problem->requiredVersionConstraint, $problem->installedVersion));
39+
}
40+
}
41+
}
Lines changed: 179 additions & 0 deletions
+
private static function cleanVersionSegment(string $segment): string
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\AssetMapper\ImportMap;
13+
14+
use Composer\Semver\Semver;
15+
use Symfony\Component\AssetMapper\Exception\RuntimeException;
16+
use Symfony\Component\HttpClient\HttpClient;
17+
use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface;
18+
use Symfony\Contracts\HttpClient\HttpClientInterface;
19+
20+
class ImportMapVersionChecker
21+
{
22+
private const PACKAGE_METADATA_PATTERN = 'https://registry.npmjs.org/%package%/%version%';
23+
24+
private HttpClientInterface $httpClient;
25+
26+
public function __construct(
27+
private ImportMapConfigReader $importMapConfigReader,
28+
private RemotePackageDownloader $packageDownloader,
29+
HttpClientInterface $httpClient = null,
30+
) {
31+
$this->httpClient = $httpClient ?? HttpClient::create();
32+
}
33+
34+
/**
35+
* @return PackageVersionProblem[]
36+
*/
37+
public function checkVersions(): array
38+
{
39+
$entries = $this->importMapConfigReader->getEntries();
40+
41+
$packages = [];
42+
foreach ($entries as $entry) {
43+
if (!$entry->isRemotePackage()) {
44+
continue;
45+
}
46+
47+
$dependencies = $this->packageDownloader->getDependencies($entry->importName);
48+
if (!$dependencies) {
49+
continue;
50+
}
51+
52+
$packageName = $entry->getPackageName();
53+
54+
$url = str_replace(
55+
['%package%', '%version%'],
56+
[$packageName, $entry->version],
57+
self::PACKAGE_METADATA_PATTERN
58+
);
59+
$packages[$packageName] = [
60+
$this->httpClient->request('GET', $url),
61+
$dependencies,
62+
];
63+
}
64+
65+
$errors = [];
66+
$problems = [];
67+
foreach ($packages as $packageName => [$response, $dependencies]) {
68+
if (200 !== $response->getStatusCode()) {
69+
$errors[] = [$packageName, $response];
70+
continue;
71+
}
72+
73+
$data = json_decode($response->getContent(), true);
74+
// dependencies seem to be found in both places
75+
$packageDependencies = array_merge(
76+
$data['dependencies'] ?? [],
77+
$data['peerDependencies'] ?? []
78+
);
79+
80+
foreach ($dependencies as $dependencyName) {
81+
// dependency is not in the import map
82+
if (!$entries->has($dependencyName)) {
83+
$dependencyVersionConstraint = $packageDependencies[$dependencyName] ?? 'unknown';
84+
$problems[] = new PackageVersionProblem($packageName, $dependencyName, $dependencyVersionConstraint, null);
85+
86+
continue;
87+
}
88+
89+
$dependencyPackageName = $entries->get($dependencyName)->getPackageName();
90+
$dependencyVersionConstraint = $packageDependencies[$dependencyPackageName] ?? null;
91+
92+
if (null === $dependencyVersionConstraint) {
93+
$problems[] = new PackageVersionProblem($packageName, $dependencyPackageName, $dependencyVersionConstraint, $entries->get($dependencyName)->version);
94+
95+
continue;
96+
}
97+
98+
if (!$this->isVersionSatisfied($dependencyVersionConstraint, $entries->get($dependencyName)->version)) {
99+
$problems[] = new PackageVersionProblem($packageName, $dependencyPackageName, $dependencyVersionConstraint, $entries->get($dependencyName)->version);
100+
}
101+
}
102+
}
103+
104+
try {
105+
($errors[0][1] ?? null)?->getHeaders();
106+
} catch (HttpExceptionInterface $e) {
107+
$response = $e->getResponse();
108+
$packageNames = implode('", "', array_column($errors, 0));
109+
110+
throw new RuntimeException(sprintf('Error %d finding metadata for package "%s". Response: ', $response->getStatusCode(), $packageNames).$response->getContent(false), 0, $e);
111+
}
112+
113+
return $problems;
114+
}
115+
116+
/**
117+
* Converts npm-specific version constraints to composer-style.
118+
*
119+
* @internal
120+
*/
121+
public static function convertNpmConstraint(string $versionConstraint): ?string
122+
{
123+
// special npm constraint that don't translate to composer
124+
if (\in_array($versionConstraint, ['latest', 'next'])
125+
|| preg_match('/^(git|http|file):/', $versionConstraint)
126+
|| str_contains($versionConstraint, '/')
127+
) {
128+
// GitHub shorthand like user/repo
129+
return null;
130+
}
131+
132+
// remove whitespace around hyphens
133+
$versionConstraint = preg_replace('/\s?-\s?/', '-', $versionConstraint);
134+
$segments = explode(' ', $versionConstraint);
135+
$processedSegments = [];
136+
137+
foreach ($segments as $segment) {
138+
if (str_contains($segment, '-') && !preg_match('/-(alpha|beta|rc)\./', $segment)) {
139+
// This is a range
140+
[$start, $end] = explode('-', $segment);
141+
$processedSegments[] = '>='.self::cleanVersionSegment(trim($start)).' <='.self::cleanVersionSegment(trim($end));
142+
} elseif (preg_match('/^~(\d+\.\d+)$/', $segment, $matches)) {
143+
// Handle the tilde when only major.minor specified
144+
$baseVersion = $matches[1];
145+
$processedSegments[] = '>='.$baseVersion.'.0';
146+
$processedSegments[] = '<'.$baseVersion[0].'.'.($baseVersion[2] + 1).'.0';
147+
} else {
148+
$processedSegments[] = self::cleanVersionSegment($segment);
149+
}
150+
}
151+
152+
return implode(' ', $processedSegments);
153+
}
154+
155
156+
{
157+
return str_replace(['v', '.x'], ['', '.*'], $segment);
158+
}
159+
160+
private function isVersionSatisfied(string $versionConstraint, ?string $version): bool
161+
{
162+
if (!$version) {
163+
return false;
164+
}
165+
166+
try {
167+
$versionConstraint = self::convertNpmConstraint($versionConstraint);
168+
169+
// if version isn't parseable/convertible, assume it's not satisfied
170+
if (null === $versionConstraint) {
171+
return false;
172+
}
173+
174+
return Semver::satisfies($version, $versionConstraint);
175+
} catch (\UnexpectedValueException $e) {
176+
return false;
177+
}
178+
}
179+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\AssetMapper\ImportMap;
13+
14+
final class PackageVersionProblem
15+
{
16+
public function __construct(
17+
public readonly string $packageName,
18+
public readonly string $dependencyPackageName,
19+
public readonly ?string $requiredVersionConstraint,
20+
public readonly ?string $installedVersion
21+
) {
22+
}
23+
}

0 commit comments

Comments
 (0)
0