| // Copyright (c) 2021, 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. |
| |
| /// This implements support for dependency-bot style automated upgrades. |
| /// It is still work in progress - do not rely on the current output. |
| import 'dart:convert'; |
| import 'dart:io'; |
| |
| import 'package:collection/collection.dart'; |
| import 'package:path/path.dart'; |
| import 'package:pub_semver/pub_semver.dart'; |
| import 'package:yaml/yaml.dart'; |
| import 'package:yaml_edit/yaml_edit.dart'; |
| |
| import '../command.dart'; |
| import '../exceptions.dart'; |
| import '../io.dart'; |
| import '../lock_file.dart'; |
| import '../log.dart' as log; |
| import '../package.dart'; |
| import '../package_name.dart'; |
| import '../pubspec.dart'; |
| import '../pubspec_utils.dart'; |
| import '../solver.dart'; |
| import '../source/git.dart'; |
| import '../system_cache.dart'; |
| import '../utils.dart'; |
| |
| class DependencyServicesReportCommand extends PubCommand { |
| @override |
| String get name => 'report'; |
| @override |
| String get description => |
| 'Output a machine-digestible report of the upgrade options for each dependency.'; |
| @override |
| String get argumentsDescription => '[options]'; |
| |
| @override |
| bool get takesArguments => false; |
| |
| DependencyServicesReportCommand() { |
| argParser.addOption('directory', |
| abbr: 'C', help: 'Run this in the directory<dir>.', valueHelp: 'dir'); |
| } |
| |
| @override |
| Future<void> runProtected() async { |
| final compatiblePubspec = stripDependencyOverrides(entrypoint.root.pubspec); |
| |
| final breakingPubspec = stripVersionUpperBounds(compatiblePubspec); |
| |
| final compatiblePackagesResult = |
| await _tryResolve(compatiblePubspec, cache); |
| |
| final breakingPackagesResult = await _tryResolve(breakingPubspec, cache); |
| |
| // The packages in the current lockfile or resolved from current pubspec.yaml. |
| late Map<String, PackageId> currentPackages; |
| |
| if (fileExists(entrypoint.lockFilePath)) { |
| currentPackages = |
| Map<String, PackageId>.from(entrypoint.lockFile.packages); |
| } else { |
| final resolution = await _tryResolve(entrypoint.root.pubspec, cache) ?? |
| (throw DataException('Failed to resolve pubspec')); |
| currentPackages = |
| Map<String, PackageId>.fromIterable(resolution, key: (e) => e.name); |
| } |
| currentPackages.remove(entrypoint.root.name); |
| |
| final dependencies = <Object>[]; |
| final result = <String, Object>{'dependencies': dependencies}; |
| |
| Future<List<Object>> _computeUpgradeSet( |
| Pubspec rootPubspec, |
| PackageId? package, { |
| required _UpgradeType upgradeType, |
| }) async { |
| if (package == null) return []; |
| final lockFile = entrypoint.lockFile; |
| final pubspec = upgradeType == _UpgradeType.multiBreaking |
| ? stripVersionUpperBounds(rootPubspec) |
| : Pubspec( |
| rootPubspec.name, |
| dependencies: rootPubspec.dependencies.values, |
| devDependencies: rootPubspec.devDependencies.values, |
| sdkConstraints: rootPubspec.sdkConstraints, |
| ); |
| |
| final dependencySet = _dependencySetOfPackage(pubspec, package); |
| if (dependencySet != null) { |
| // Force the version to be the new version. |
| dependencySet[package.name] = |
| package.toRef().withConstraint(package.toRange().constraint); |
| } |
| |
| final resolution = await tryResolveVersions( |
| SolveType.get, |
| cache, |
| Package.inMemory(pubspec), |
| lockFile: lockFile, |
| ); |
| |
| // TODO(sigurdm): improve error messages. |
| if (resolution == null) { |
| throw DataException('Failed resolving'); |
| } |
| |
| return [ |
| ...resolution.packages.where((r) { |
| if (r.name == rootPubspec.name) return false; |
| final originalVersion = currentPackages[r.name]; |
| return originalVersion == null || r != originalVersion; |
| }).map((p) { |
| final depset = _dependencySetOfPackage(rootPubspec, p); |
| final originalConstraint = depset?[p.name]?.constraint; |
| final currentPackage = currentPackages[p.name]; |
| return { |
| 'name': p.name, |
| 'version': p.versionOrHash(), |
| 'kind': _kindString(pubspec, p.name), |
| 'source': _source(p, containingDir: directory), |
| 'constraintBumped': originalConstraint == null |
| ? null |
| : upgradeType == _UpgradeType.compatible |
| ? originalConstraint.toString() |
| : VersionConstraint.compatibleWith(p.version).toString(), |
| 'constraintWidened': originalConstraint == null |
| ? null |
| : upgradeType == _UpgradeType.compatible |
| ? originalConstraint.toString() |
| : _widenConstraint(originalConstraint, p.version) |
| .toString(), |
| 'constraintBumpedIfNeeded': originalConstraint == null |
| ? null |
| : upgradeType == _UpgradeType.compatible |
| ? originalConstraint.toString() |
| : originalConstraint.allows(p.version) |
| ? originalConstraint.toString() |
| : VersionConstraint.compatibleWith(p.version) |
| .toString(), |
| 'previousVersion': currentPackage?.versionOrHash(), |
| 'previousConstraint': originalConstraint?.toString(), |
| 'previousSource': currentPackage == null |
| ? null |
| : _source(currentPackage, containingDir: directory), |
| }; |
| }), |
| // Find packages that were removed by the resolution |
| for (final oldPackageName in lockFile.packages.keys) |
| if (!resolution.packages |
| .any((newPackage) => newPackage.name == oldPackageName)) |
| { |
| 'name': oldPackageName, |
| 'version': null, |
| 'kind': |
| 'transitive', // Only transitive constraints can be removed. |
| 'constraintBumped': null, |
| 'constraintWidened': null, |
| 'constraintBumpedIfNeeded': null, |
| 'previousVersion': |
| currentPackages[oldPackageName]?.versionOrHash(), |
| 'previousConstraint': null, |
| 'previous': _source(currentPackages[oldPackageName]!, |
| containingDir: directory) |
| }, |
| ]; |
| } |
| |
| for (final package in currentPackages.values) { |
| final compatibleVersion = compatiblePackagesResult |
| ?.firstWhereOrNull((element) => element.name == package.name); |
| final multiBreakingVersion = breakingPackagesResult |
| ?.firstWhereOrNull((element) => element.name == package.name); |
| final singleBreakingPubspec = Pubspec( |
| compatiblePubspec.name, |
| version: compatiblePubspec.version, |
| sdkConstraints: compatiblePubspec.sdkConstraints, |
| dependencies: compatiblePubspec.dependencies.values, |
| devDependencies: compatiblePubspec.devDependencies.values, |
| ); |
| final dependencySet = |
| _dependencySetOfPackage(singleBreakingPubspec, package); |
| final kind = _kindString(compatiblePubspec, package.name); |
| PackageId? singleBreakingVersion; |
| if (dependencySet != null) { |
| dependencySet[package.name] = package |
| .toRef() |
| .withConstraint(stripUpperBound(package.toRange().constraint)); |
| final singleBreakingPackagesResult = |
| await _tryResolve(singleBreakingPubspec, cache); |
| singleBreakingVersion = singleBreakingPackagesResult |
| ?.firstWhereOrNull((element) => element.name == package.name); |
| } |
| dependencies.add({ |
| 'name': package.name, |
| 'version': package.versionOrHash(), |
| 'kind': kind, |
| 'source': _source(package, containingDir: directory), |
| 'latest': |
| (await cache.getLatest(package.toRef(), version: package.version)) |
| ?.versionOrHash(), |
| 'constraint': |
| _constraintOf(compatiblePubspec, package.name)?.toString(), |
| 'compatible': await _computeUpgradeSet( |
| compatiblePubspec, compatibleVersion, |
| upgradeType: _UpgradeType.compatible), |
| 'singleBreaking': kind != 'transitive' && singleBreakingVersion == null |
| ? [] |
| : await _computeUpgradeSet(compatiblePubspec, singleBreakingVersion, |
| upgradeType: _UpgradeType.singleBreaking), |
| 'multiBreaking': kind != 'transitive' && multiBreakingVersion != null |
| ? await _computeUpgradeSet(compatiblePubspec, multiBreakingVersion, |
| upgradeType: _UpgradeType.multiBreaking) |
| : [], |
| }); |
| } |
| log.message(JsonEncoder.withIndent(' ').convert(result)); |
| } |
| } |
| |
| VersionConstraint? _constraintOf(Pubspec pubspec, String packageName) { |
| return (pubspec.dependencies[packageName] ?? |
| pubspec.devDependencies[packageName]) |
| ?.constraint; |
| } |
| |
| String _kindString(Pubspec pubspec, String packageName) { |
| return pubspec.dependencies.containsKey(packageName) |
| ? 'direct' |
| : pubspec.devDependencies.containsKey(packageName) |
| ? 'dev' |
| : 'transitive'; |
| } |
| |
| Map<String, Object?> _source(PackageId id, {required String containingDir}) { |
| return { |
| 'type': id.source.name, |
| 'description': |
| id.description.serializeForLockfile(containingDir: containingDir), |
| }; |
| } |
| |
| /// Try to solve [pubspec] return [PackageId]s in the resolution or `null` if no |
| /// resolution was found. |
| Future<List<PackageId>?> _tryResolve(Pubspec pubspec, SystemCache cache) async { |
| final solveResult = await tryResolveVersions( |
| SolveType.upgrade, |
| cache, |
| Package.inMemory(pubspec), |
| ); |
| |
| return solveResult?.packages; |
| } |
| |
| class DependencyServicesListCommand extends PubCommand { |
| @override |
| String get name => 'list'; |
| |
| @override |
| String get description => |
| 'Output a machine digestible listing of all dependencies'; |
| |
| @override |
| bool get takesArguments => false; |
| |
| DependencyServicesListCommand() { |
| argParser.addOption('directory', |
| abbr: 'C', help: 'Run this in the directory<dir>.', valueHelp: 'dir'); |
| } |
| |
| @override |
| Future<void> runProtected() async { |
| final pubspec = entrypoint.root.pubspec; |
| |
| final currentPackages = fileExists(entrypoint.lockFilePath) |
| ? entrypoint.lockFile.packages.values.toList() |
| : (await _tryResolve(pubspec, cache) ?? <PackageId>[]); |
| |
| final dependencies = <Object>[]; |
| final result = <String, Object>{'dependencies': dependencies}; |
| |
| for (final package in currentPackages) { |
| dependencies.add({ |
| 'name': package.name, |
| 'version': package.versionOrHash(), |
| 'kind': _kindString(pubspec, package.name), |
| 'constraint': _constraintOf(pubspec, package.name).toString(), |
| 'source': _source(package, containingDir: directory), |
| }); |
| } |
| log.message(JsonEncoder.withIndent(' ').convert(result)); |
| } |
| } |
| |
| extension on PackageId { |
| String versionOrHash() { |
| final description = this.description; |
| if (description is GitResolvedDescription) { |
| return description.resolvedRef; |
| } else { |
| return version.toString(); |
| } |
| } |
| } |
| |
| enum _UpgradeType { |
| /// Only upgrade pubspec.lock. |
| compatible, |
| |
| /// Unlock at most one dependency in pubspec.yaml. |
| singleBreaking, |
| |
| /// Unlock any dependencies in pubspec.yaml needed for getting the |
| /// latest resolvable version. |
| multiBreaking, |
| } |
| |
| class DependencyServicesApplyCommand extends PubCommand { |
| @override |
| String get name => 'apply'; |
| |
| @override |
| String get description => |
| 'Updates pubspec.yaml and pubspec.lock according to input.'; |
| |
| @override |
| bool get takesArguments => true; |
| |
| DependencyServicesApplyCommand() { |
| argParser.addOption('directory', |
| abbr: 'C', help: 'Run this in the directory <dir>.', valueHelp: 'dir'); |
| } |
| |
| @override |
| Future<void> runProtected() async { |
| YamlEditor(readTextFile(entrypoint.pubspecPath)); |
| final toApply = <_PackageVersion>[]; |
| final input = json.decode(await utf8.decodeStream(stdin)); |
| for (final change in input['dependencyChanges']) { |
| toApply.add( |
| _PackageVersion( |
| change['name'], |
| change['version'], |
| change['constraint'] != null |
| ? VersionConstraint.parse(change['constraint']) |
| : null, |
| ), |
| ); |
| } |
| |
| final pubspec = entrypoint.root.pubspec; |
| final pubspecEditor = YamlEditor(readTextFile(entrypoint.pubspecPath)); |
| final lockFile = fileExists(entrypoint.lockFilePath) |
| ? readTextFile(entrypoint.lockFilePath) |
| : null; |
| final lockFileYaml = lockFile == null ? null : loadYaml(lockFile); |
| final lockFileEditor = lockFile == null ? null : YamlEditor(lockFile); |
| for (final p in toApply) { |
| final targetPackage = p.name; |
| final targetVersion = p.version; |
| final targetConstraint = p.constraint; |
| final targetRevision = p.gitRevision; |
| |
| if (targetConstraint != null) { |
| final section = pubspec.dependencies[targetPackage] != null |
| ? 'dependencies' |
| : 'dev_dependencies'; |
| final packageConfig = |
| pubspecEditor.parseAt([section, targetPackage]).value; |
| if (packageConfig == null || packageConfig is String) { |
| pubspecEditor |
| .update([section, targetPackage], targetConstraint.toString()); |
| } else if (packageConfig is Map) { |
| pubspecEditor.update( |
| [section, targetPackage, 'version'], targetConstraint.toString()); |
| } else { |
| fail( |
| 'The dependency $targetPackage does not have a map or string as a description'); |
| } |
| } else if (targetVersion != null) { |
| final constraint = _constraintOf(pubspec, targetPackage); |
| if (constraint != null && !constraint.allows(targetVersion)) { |
| final section = pubspec.dependencies[targetPackage] != null |
| ? 'dependencies' |
| : 'dev_dependencies'; |
| pubspecEditor.update([section, targetPackage], |
| VersionConstraint.compatibleWith(targetVersion).toString()); |
| } |
| } |
| if (lockFileEditor != null) { |
| if (targetVersion != null && |
| lockFileYaml['packages'].containsKey(targetPackage)) { |
| lockFileEditor.update( |
| ['packages', targetPackage, 'version'], targetVersion.toString()); |
| } else if (targetRevision != null && |
| lockFileYaml['packages'].containsKey(targetPackage)) { |
| final ref = entrypoint.lockFile.packages[targetPackage]!.toRef(); |
| final currentDescription = ref.description as GitDescription; |
| final updatedRef = PackageRef( |
| targetPackage, |
| GitDescription( |
| url: currentDescription.url, |
| path: currentDescription.path, |
| ref: targetRevision, |
| containingDir: directory)); |
| final versions = await cache.getVersions(updatedRef); |
| if (versions.isEmpty) { |
| dataError( |
| 'Found no versions of $targetPackage with git revision `$targetRevision`.'); |
| } |
| // GitSource can only return a single version. |
| assert(versions.length == 1); |
| |
| lockFileEditor.update(['packages', targetPackage, 'version'], |
| versions.single.version.toString()); |
| lockFileEditor.update( |
| ['packages', targetPackage, 'description', 'resolved-ref'], |
| targetRevision, |
| ); |
| } else if (targetVersion == null && |
| targetRevision == null && |
| !lockFileYaml['packages'].containsKey(targetPackage)) { |
| dataError( |
| 'Trying to remove non-existing transitive dependency $targetPackage.', |
| ); |
| } |
| } |
| } |
| |
| final updatedLockfile = lockFileEditor == null |
| ? null |
| : LockFile.parse( |
| lockFileEditor.toString(), |
| cache.sources, |
| filePath: entrypoint.lockFilePath, |
| ); |
| await log.warningsOnlyUnlessTerminal( |
| () async { |
| final updatedPubspec = pubspecEditor.toString(); |
| // Resolve versions, this will update transitive dependencies that were |
| // not passed in the input. And also counts as a validation of the input |
| // by ensuring the resolution is valid. |
| // |
| // We don't use `acquireDependencies` as that downloads all the archives |
| // to cache. |
| // TODO: Handle HTTP exceptions gracefully! |
| final solveResult = await resolveVersions( |
| SolveType.get, |
| cache, |
| Package.inMemory(Pubspec.parse(updatedPubspec, cache.sources, |
| location: toUri(entrypoint.pubspecPath))), |
| lockFile: updatedLockfile, |
| ); |
| if (pubspecEditor.edits.isNotEmpty) { |
| writeTextFile(entrypoint.pubspecPath, updatedPubspec); |
| } |
| // Only if we originally had a lock-file we write the resulting lockfile back. |
| if (lockFileEditor != null) { |
| entrypoint.saveLockFile(solveResult); |
| } |
| }, |
| ); |
| // Dummy message. |
| log.message(json.encode({'dependencies': []})); |
| } |
| } |
| |
| class _PackageVersion { |
| String name; |
| Version? version; |
| String? gitRevision; |
| VersionConstraint? constraint; |
| _PackageVersion(this.name, String? versionOrHash, this.constraint) |
| : version = |
| versionOrHash == null ? null : _tryParseVersion(versionOrHash), |
| gitRevision = |
| versionOrHash == null ? null : _tryParseHash(versionOrHash); |
| } |
| |
| Version? _tryParseVersion(String v) { |
| try { |
| return Version.parse(v); |
| } on FormatException { |
| return null; |
| } |
| } |
| |
| String? _tryParseHash(String v) { |
| if (RegExp(r'^[a-fA-F0-9]+$').hasMatch(v)) { |
| return v; |
| } |
| return null; |
| } |
| |
| Map<String, PackageRange>? _dependencySetOfPackage( |
| Pubspec pubspec, PackageId package) { |
| return pubspec.dependencies.containsKey(package.name) |
| ? pubspec.dependencies |
| : pubspec.devDependencies.containsKey(package.name) |
| ? pubspec.devDependencies |
| : null; |
| } |
| |
| VersionConstraint _widenConstraint( |
| VersionConstraint original, Version newVersion) { |
| if (original.allows(newVersion)) return original; |
| if (original is VersionRange) { |
| final min = original.min; |
| final max = original.max; |
| if (max != null && newVersion >= max) { |
| return _compatibleWithIfPossible( |
| VersionRange( |
| min: min, |
| includeMin: original.includeMin, |
| max: newVersion.nextBreaking.firstPreRelease, |
| ), |
| ); |
| } |
| if (min != null && newVersion <= min) { |
| return _compatibleWithIfPossible( |
| VersionRange( |
| min: newVersion, |
| includeMin: true, |
| max: max, |
| includeMax: original.includeMax), |
| ); |
| } |
| } |
| |
| if (original.isEmpty) return newVersion; |
| throw ArgumentError.value( |
| original, 'original', 'Must be a Version range or empty'); |
| } |
| |
| VersionConstraint _compatibleWithIfPossible(VersionRange versionRange) { |
| final min = versionRange.min; |
| if (min != null && min.nextBreaking.firstPreRelease == versionRange.max) { |
| return VersionConstraint.compatibleWith(min); |
| } |
| return versionRange; |
| } |