| // Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file | |
| // for details. All rights reserved. Use of this source code is governed by a | |
| // BSD-style license that can be found in the LICENSE file. | |
| import 'package:collection/collection.dart'; | |
| import 'package:pub_semver/pub_semver.dart'; | |
| import '../command_runner.dart'; | |
| import '../lock_file.dart'; | |
| import '../log.dart' as log; | |
| import '../package_name.dart'; | |
| import '../path.dart'; | |
| import '../pubspec.dart'; | |
| import '../source/hosted.dart'; | |
| import '../source/root.dart'; | |
| import '../system_cache.dart'; | |
| import '../utils.dart'; | |
| import 'result.dart'; | |
| import 'type.dart'; | |
| /// The level of reporting to output upon successful dependency resolution. | |
| enum SolveReportMode { | |
| /// No output on success. | |
| none, | |
| /// Only a single-line summary (e.g. `Got dependencies.`). | |
| summaryOnly, | |
| /// Full report showing package version changes and summary. | |
| full, | |
| } | |
| /// Unlike [SolveResult], which is the static data describing a resolution, | |
| /// this class contains the mutable state used while generating the report | |
| /// itself. | |
| /// | |
| /// It's a report builder. | |
| class SolveReport { | |
| final SolveType _type; | |
| // The report will contain "in [_location]" if given. | |
| final String? _location; | |
| final Pubspec _rootPubspec; | |
| final LockFile _previousLockFile; | |
| final LockFile _newLockFile; | |
| final SystemCache _cache; | |
| final bool _dryRun; | |
| final Map<String, PackageRange> _overriddenPackages; | |
| final SolveReportMode _reportMode; | |
| final bool _enforceLockfile; | |
| /// The available versions of all selected packages from their source. | |
| /// | |
| /// An entry here may not include the full list of versions available if the | |
| /// given package was locked and did not need to be unlocked during the solve. | |
| /// | |
| /// Version list will not contain any retracted package versions. | |
| final Map<String, List<Version>> _availableVersions; | |
| static const maxAdvisoryFootnotesPerLine = 5; | |
| final advisoryDisplayHandles = <String>[]; | |
| SolveReport( | |
| this._type, | |
| this._location, | |
| this._rootPubspec, | |
| this._overriddenPackages, | |
| this._previousLockFile, | |
| this._newLockFile, | |
| this._availableVersions, | |
| this._cache, { | |
| required bool dryRun, | |
| required bool enforceLockfile, | |
| required SolveReportMode reportMode, | |
| }) : _dryRun = dryRun, | |
| _reportMode = reportMode, | |
| _enforceLockfile = enforceLockfile; | |
| /// Displays a report of the results of the version resolution in | |
| /// [_newLockFile] relative to the [_previousLockFile] file. | |
| /// | |
| /// If [summary] is `true` a count of changes and number of | |
| /// discontinued/retracted packages will be shown at the end of the report. | |
| Future<void> show({required bool summary}) async { | |
| final changes = await _reportChanges(); | |
| _checkContentHashesMatchOldLockfile(); | |
| if (summary) await summarize(changes); | |
| } | |
| void _checkContentHashesMatchOldLockfile() { | |
| final issues = <String>[]; | |
| final newPackageNames = _newLockFile.packages.keys.toSet(); | |
| final oldPackageNames = _previousLockFile.packages.keys.toSet(); | |
| // We only care about packages that exist in both new and old lockfile. | |
| for (final name in newPackageNames.intersection(oldPackageNames)) { | |
| final newId = _newLockFile.packages[name]!; | |
| final oldId = _previousLockFile.packages[name]!; | |
| // We only care about hosted packages | |
| final newDescription = newId.description; | |
| final oldDescription = oldId.description; | |
| if (newDescription is! ResolvedHostedDescription || | |
| oldDescription is! ResolvedHostedDescription) { | |
| continue; | |
| } | |
| // We don't care about changes in the hash if the version number changed! | |
| if (newId.version != oldId.version) { | |
| continue; | |
| } | |
| // Use the cached content-hashes after downloading to ensure that | |
| // content-hashes from legacy servers gets used. | |
| final cachedHash = newDescription.sha256; | |
| assert(cachedHash != null); | |
| // Ignore cases where the old lockfile doesn't have a content-hash | |
| final oldHash = oldDescription.sha256; | |
| if (oldHash == null) { | |
| continue; | |
| } | |
| if (!fixedTimeBytesEquals(cachedHash, oldHash)) { | |
| issues.add( | |
| '$name-${newId.version} from "${newDescription.description.url}"', | |
| ); | |
| } | |
| } | |
| if (issues.isNotEmpty) { | |
| warning(''' | |
| The existing content-hash from pubspec.lock doesn't match contents for: | |
| * ${issues.join('\n * ')} | |
| This indicates one of: | |
| * The content has changed on the server since you created the pubspec.lock. | |
| * The pubspec.lock has been corrupted. | |
| ${_dryRun || _enforceLockfile ? '' : '\nThe content-hashes in pubspec.lock has been updated.'} | |
| For more information see: | |
| $contentHashesDocumentationUrl | |
| '''); | |
| } | |
| } | |
| /// Displays a one-line message summarizing what changes were made (or would | |
| /// be made) to the lockfile. | |
| /// | |
| /// If [_dryRun] or [_enforceLockfile] is true, describes it in terms of what | |
| /// would be done. | |
| /// | |
| /// [_type] is the type of version resolution that was run. | |
| /// If [_type] is `SolveType.UPGRADE` it also shows the number of packages | |
| /// that are not at the latest available version and the number of outdated | |
| /// packages. | |
| Future<void> summarize(int changes) async { | |
| // Count how many dependencies actually changed. | |
| final dependencies = _newLockFile.packages.keys.toSet(); | |
| dependencies.addAll(_previousLockFile.packages.keys); | |
| dependencies.remove(_rootPubspec.name); | |
| var suffix = ''; | |
| final dir = _location; | |
| if (dir != null) { | |
| if (dir != '.') { | |
| suffix = ' in `$dir`'; | |
| } | |
| } | |
| switch (_reportMode) { | |
| case SolveReportMode.none: | |
| if (_dryRun) { | |
| log.fine('Would get dependencies$suffix.'); | |
| } else if (_enforceLockfile) { | |
| if (changes == 0) { | |
| log.fine('Got dependencies$suffix.'); | |
| } | |
| } else { | |
| log.fine('Got dependencies$suffix.'); | |
| } | |
| case SolveReportMode.summaryOnly: | |
| if (_dryRun) { | |
| log.message('Would get dependencies$suffix.'); | |
| } else if (_enforceLockfile) { | |
| if (changes == 0) { | |
| log.message('Got dependencies$suffix.'); | |
| } | |
| } else { | |
| log.message('Got dependencies$suffix.'); | |
| } | |
| case SolveReportMode.full: | |
| if (_dryRun) { | |
| if (changes == 0) { | |
| log.message('No dependencies would change$suffix.'); | |
| } else if (changes == 1) { | |
| log.message('Would change $changes dependency$suffix.'); | |
| } else { | |
| log.message('Would change $changes dependencies$suffix.'); | |
| } | |
| } else if (_enforceLockfile) { | |
| if (changes == 0) { | |
| log.message('Got dependencies$suffix!'); | |
| } else if (changes == 1) { | |
| log.message('Would change $changes dependency$suffix.'); | |
| } else { | |
| log.message('Would change $changes dependencies$suffix.'); | |
| } | |
| } else { | |
| if (changes == 0) { | |
| if (_type == SolveType.get) { | |
| log.message('Got dependencies$suffix!'); | |
| } else { | |
| log.message('No dependencies changed$suffix.'); | |
| } | |
| } else if (changes == 1) { | |
| log.message('Changed $changes dependency$suffix!'); | |
| } else { | |
| log.message('Changed $changes dependencies$suffix!'); | |
| } | |
| } | |
| await reportDiscontinued(); | |
| reportAdvisories(); | |
| reportOutdated(); | |
| } | |
| } | |
| /// Displays a report of all of the previous and current dependencies and | |
| /// how they have changed. | |
| /// | |
| /// Returns the number of changes. | |
| Future<int> _reportChanges() async { | |
| final output = StringBuffer(); | |
| // Show the new set of dependencies ordered by name. | |
| final names = _newLockFile.packages.keys.toList(); | |
| names.remove(_rootPubspec.name); | |
| names.sort(); | |
| if (_type != SolveType.downgrade) { | |
| await _cache.prefetchAdvisoriesAndStatus( | |
| names.map((name) => _newLockFile.packages[name]).nonNulls, | |
| ); | |
| } | |
| var changes = 0; | |
| for (final name in names) { | |
| changes += await _reportPackage(name, output) ? 1 : 0; | |
| } | |
| // Show any removed ones. | |
| final removed = _previousLockFile.packages.keys.toSet(); | |
| removed.removeAll(names); | |
| removed.remove(_rootPubspec.name); // Never consider root. | |
| if (removed.isNotEmpty) { | |
| output.writeln('These packages are no longer being depended on:'); | |
| for (var name in removed.sorted()) { | |
| await _reportPackage(name, output, alwaysShow: true); | |
| changes += 1; | |
| } | |
| } | |
| message(output.toString()); | |
| return changes; | |
| } | |
| /// Displays a single-line message, number of discontinued packages | |
| /// if discontinued packages are detected. | |
| Future<void> reportDiscontinued() async { | |
| var numDiscontinued = 0; | |
| for (var id in _newLockFile.packages.values) { | |
| if (id.description is RootDescription) continue; | |
| final status = await id.source.status( | |
| id.toRef(), | |
| id.version, | |
| _cache, | |
| maxAge: const Duration(days: 3), | |
| ); | |
| if (status.isDiscontinued && | |
| (_rootPubspec.dependencyType(id.name) == DependencyType.direct || | |
| _rootPubspec.dependencyType(id.name) == DependencyType.dev)) { | |
| numDiscontinued++; | |
| } | |
| } | |
| if (numDiscontinued > 0) { | |
| if (numDiscontinued == 1) { | |
| message('1 package is discontinued.'); | |
| } else { | |
| message('$numDiscontinued packages are discontinued.'); | |
| } | |
| } | |
| } | |
| /// Displays a two-line message, number of outdated packages and an | |
| /// instruction to run `pub outdated` if outdated packages are detected. | |
| void reportOutdated() { | |
| final outdatedPackagesCount = | |
| _newLockFile.packages.values.where((id) { | |
| final versions = _availableVersions[id.name]!; | |
| // A version is counted: | |
| // - if there is a newer version which is not a pre-release and | |
| // current version is also not a pre-release or, | |
| // - if the current version is pre-release then any upgraded version | |
| // is considered. | |
| return versions.any( | |
| (v) => | |
| v > id.version && (id.version.isPreRelease || !v.isPreRelease), | |
| ); | |
| }).length; | |
| if (outdatedPackagesCount > 0) { | |
| String packageCountString; | |
| if (outdatedPackagesCount == 1) { | |
| packageCountString = '1 package has'; | |
| } else { | |
| packageCountString = '$outdatedPackagesCount packages have'; | |
| } | |
| message( | |
| '$packageCountString newer versions incompatible with ' | |
| 'dependency constraints.\n' | |
| 'Try `$topLevelProgram pub outdated` for more information.', | |
| ); | |
| } | |
| } | |
| void reportAdvisories() { | |
| if (advisoryDisplayHandles.isNotEmpty) { | |
| message('Dependencies are affected by security advisories:'); | |
| for ( | |
| var footnote = 0; | |
| footnote < advisoryDisplayHandles.length; | |
| footnote++ | |
| ) { | |
| message(' [^$footnote]: ${advisoryDisplayHandles[footnote]}'); | |
| } | |
| } | |
| } | |
| static DependencyType dependencyType(LockFile lockFile, String name) => | |
| lockFile.mainDependencies.contains(name) | |
| ? DependencyType.direct | |
| : lockFile.devDependencies.contains(name) | |
| ? DependencyType.dev | |
| : DependencyType.none; | |
| String? _constructAdvisoriesMessage( | |
| List<int> footnotes, | |
| bool advisoriesTruncated, | |
| ) { | |
| if (footnotes.isNotEmpty) { | |
| final advisoryString = footnotes.length == 1 ? 'advisory' : 'advisories'; | |
| final buffer = StringBuffer('affected by $advisoryString: '); | |
| buffer.write('[^${footnotes.first}]'); | |
| for (final footnote in footnotes.getRange(1, footnotes.length)) { | |
| buffer.write(', [^$footnote]'); | |
| } | |
| if (advisoriesTruncated) { | |
| buffer.write(', ...'); | |
| } | |
| return buffer.toString(); | |
| } | |
| return null; | |
| } | |
| /// Reports the results of the upgrade on the package named [name]. | |
| /// | |
| /// If [alwaysShow] is true, the package is reported even if it didn't change, | |
| /// regardless of [_type]. | |
| /// | |
| /// Returns true if the package had changed. | |
| Future<bool> _reportPackage( | |
| String name, | |
| StringBuffer output, { | |
| bool alwaysShow = false, | |
| }) async { | |
| final newId = _newLockFile.packages[name]; | |
| final oldId = _previousLockFile.packages[name]; | |
| final id = newId ?? oldId!; | |
| final isOverridden = _overriddenPackages.containsKey(id.name); | |
| // If the package was previously a dependency but the dependency has | |
| // changed in some way. | |
| var changed = false; | |
| // If the dependency was added or removed. | |
| var addedOrRemoved = false; | |
| // Show a one-character "icon" describing the change. They are: | |
| // | |
| // ! The package is being overridden. | |
| // - The package was removed. | |
| // + The package was added. | |
| // > The package was upgraded from a lower version. | |
| // < The package was downgraded from a higher version. | |
| // ~ Package contents has changed, but not the version number. | |
| // * Any other change between the old and new package. | |
| String icon; | |
| if (isOverridden) { | |
| icon = log.magenta('! '); | |
| } else if (newId == null) { | |
| icon = log.red('- '); | |
| addedOrRemoved = true; | |
| } else if (oldId == null) { | |
| icon = log.green('+ '); | |
| addedOrRemoved = true; | |
| } else if (oldId.description.description != newId.description.description) { | |
| // Eg. a changed source in pubspec.yaml. | |
| icon = log.cyan('* '); | |
| changed = true; | |
| } else if (oldId.version < newId.version) { | |
| icon = log.green('> '); | |
| changed = true; | |
| } else if (oldId.version > newId.version) { | |
| icon = log.cyan('< '); | |
| changed = true; | |
| } else if (oldId.description != newId.description) { | |
| // Eg. a changed hash or revision. | |
| icon = log.cyan('~ '); | |
| changed = true; | |
| } else { | |
| // Unchanged. | |
| icon = ' '; | |
| } | |
| String? message; | |
| // See if there are any newer versions of the package that we were | |
| // unable to upgrade to. | |
| if (newId != null && _type != SolveType.downgrade) { | |
| final versions = _availableVersions[newId.name]!; | |
| var newerStable = false; | |
| var newerUnstable = false; | |
| for (var version in versions) { | |
| if (version > newId.version) { | |
| if (version.isPreRelease) { | |
| newerUnstable = true; | |
| } else { | |
| newerStable = true; | |
| } | |
| } | |
| } | |
| final status = await id.source.status( | |
| id.toRef(), | |
| id.version, | |
| _cache, | |
| maxAge: const Duration(days: 3), | |
| ); | |
| final notes = <String>[]; | |
| final advisories = await id.source.getAdvisoriesForPackageVersion( | |
| id, | |
| _cache, | |
| const Duration(days: 3), | |
| ); | |
| if (advisories != null && advisories.isNotEmpty) { | |
| final advisoryFootnotes = <int>[]; | |
| final reportedAdvisories = advisories | |
| .where( | |
| (adv) => | |
| _rootPubspec.ignoredAdvisories.intersection({ | |
| ...adv.aliases, | |
| adv.id, | |
| }).isEmpty, | |
| ) | |
| .take(maxAdvisoryFootnotesPerLine); | |
| for (final adv in reportedAdvisories) { | |
| advisoryFootnotes.add(advisoryDisplayHandles.length); | |
| advisoryDisplayHandles.add(adv.displayHandle); | |
| } | |
| final advisoriesMessage = _constructAdvisoriesMessage( | |
| advisoryFootnotes, | |
| advisories.length > maxAdvisoryFootnotesPerLine, | |
| ); | |
| if (advisoriesMessage != null) { | |
| notes.add(advisoriesMessage); | |
| } | |
| } | |
| if (status.isRetracted) { | |
| if (newerStable) { | |
| notes.add( | |
| 'retracted, ${maxAll(versions, Version.prioritize)} available', | |
| ); | |
| } else if (newId.version.isPreRelease && newerUnstable) { | |
| notes.add('retracted, ${maxAll(versions)} available'); | |
| } else { | |
| notes.add('retracted'); | |
| } | |
| } else if (status.isDiscontinued && | |
| [ | |
| DependencyType.direct, | |
| DependencyType.dev, | |
| ].contains(_rootPubspec.dependencyType(name))) { | |
| if (status.discontinuedReplacedBy == null) { | |
| notes.add('discontinued'); | |
| } else { | |
| notes.add( | |
| 'discontinued replaced by ${status.discontinuedReplacedBy}', | |
| ); | |
| } | |
| } else if (newerStable) { | |
| // If there are newer stable versions, only show those. | |
| notes.add('${maxAll(versions, Version.prioritize)} available'); | |
| } else if ( | |
| // Only show newer prereleases for versions where a prerelease is | |
| // already chosen. | |
| newId.version.isPreRelease && newerUnstable) { | |
| notes.add('${maxAll(versions)} available'); | |
| } | |
| message = notes.isEmpty ? null : '(${notes.join(', ')})'; | |
| } | |
| final oldDependencyType = dependencyType(_previousLockFile, name); | |
| final newDependencyType = dependencyType(_newLockFile, name); | |
| final dependencyTypeChanged = | |
| oldId != null && | |
| newId != null && | |
| oldDependencyType != newDependencyType; | |
| if (!(alwaysShow || | |
| changed || | |
| addedOrRemoved || | |
| dependencyTypeChanged || | |
| message != null || | |
| isOverridden)) { | |
| return changed || addedOrRemoved || dependencyTypeChanged; | |
| } | |
| output.write(icon); | |
| output.write(log.bold(id.name)); | |
| output.write(' '); | |
| _writeId(id, output); | |
| // If the package was upgraded, show what it was upgraded from. | |
| if (changed) { | |
| output.write(' (was '); | |
| _writeId(oldId!, output); | |
| output.write(')'); | |
| } | |
| if (dependencyTypeChanged) { | |
| if (dependencyTypeChanged) { | |
| output.write(' (from '); | |
| _writeDependencyType(oldDependencyType, output); | |
| output.write(' dependency to '); | |
| _writeDependencyType(newDependencyType, output); | |
| output.write(' dependency)'); | |
| } | |
| } | |
| // Highlight overridden packages. | |
| if (isOverridden) { | |
| final location = _location; | |
| final overrideLocation = | |
| location != null && _rootPubspec.dependencyOverridesFromOverridesFile | |
| ? ' in ${p.join(location, Pubspec.pubspecOverridesFilename)}' | |
| : ''; | |
| output.write(' ${log.magenta('(overridden$overrideLocation)')}'); | |
| } | |
| if (message != null) output.write(' ${log.cyan(message)}'); | |
| output.writeln(); | |
| return changed || addedOrRemoved || dependencyTypeChanged; | |
| } | |
| /// Writes a terse description of [id] (not including its name) to the output. | |
| void _writeId(PackageId id, StringBuffer output) { | |
| output.write(id.version); | |
| if (id.source != _cache.defaultSource) { | |
| final description = id.description.format(); | |
| output.write(' from ${id.source} $description'); | |
| } | |
| } | |
| void _writeDependencyType(DependencyType t, StringBuffer output) { | |
| output.write( | |
| log.bold(switch (t) { | |
| DependencyType.direct => 'direct', | |
| DependencyType.dev => 'dev', | |
| DependencyType.none => 'transitive', | |
| }), | |
| ); | |
| } | |
| void warning(String message) { | |
| if (_reportMode != SolveReportMode.full) { | |
| log.fine(message); | |
| } else { | |
| log.warning(message); | |
| } | |
| } | |
| void message(String message) { | |
| if (_reportMode != SolveReportMode.full) { | |
| log.fine(message); | |
| } else { | |
| log.message(message); | |
| } | |
| } | |
| } |