blob: 13c6e70132e5becb4910b2632a371d796cdff78c [file] [log] [blame]
// 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 '../source/hosted.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.where((p) => !p.isRoot)) {
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);
final hasContentHashes = _lockFileHasContentHashes(lockFileYaml);
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());
// Remove the now outdated content-hash - it will be restored below
// after resolution.
if (lockFileEditor
.parseAt(['packages', targetPackage, 'description'])
.value
.containsKey('sha256')) {
lockFileEditor.remove(
['packages', targetPackage, 'description', 'sha256'],
);
}
} 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 (updatedLockfile != null) {
final updatedPackages = <PackageId>[];
for (var package in solveResult.packages) {
if (package.isRoot) continue;
final description = package.description;
// Handle content-hashes of hosted dependencies.
if (description is ResolvedHostedDescription) {
// Ensure we get content-hashes if the original lock-file had
// them.
if (hasContentHashes) {
if (description.sha256 == null) {
// We removed the hash above before resolution - as we get the
// locked id back we need to find the content-hash from the
// version listing.
//
// `pub get` gets this version-listing from the downloaded
// archive but we don't want to download all archives - so we
// copy it from the version listing.
package = (await cache.getVersions(package.toRef()))
.firstWhere((id) => id == package, orElse: () => package);
if ((package.description as ResolvedHostedDescription)
.sha256 ==
null) {
// This happens when we resolved a package from a legacy
// server not providing archive_sha256. As a side-effect of
// downloading the package we compute and store the sha256.
package = await cache.downloadPackage(package);
}
}
} else {
// The original pubspec.lock did not have content-hashes. Remove
// any content hash, so we don't start adding them.
package = PackageId(
package.name,
package.version,
description.withSha256(null),
);
}
}
updatedPackages.add(package);
}
final newLockFile = LockFile(
updatedPackages,
sdkConstraints: updatedLockfile.sdkConstraints,
mainDependencies: pubspec.dependencies.keys.toSet(),
devDependencies: pubspec.devDependencies.keys.toSet(),
overriddenDependencies: pubspec.dependencyOverrides.keys.toSet(),
);
newLockFile.writeToFile(entrypoint.lockFilePath, cache);
}
},
);
// 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;
}
/// `true` iff any of the packages described by the [lockfile] has a
/// content-hash.
///
/// Undefined for invalid lock files, but mostly `true`.
bool _lockFileHasContentHashes(dynamic lockfile) {
if (lockfile is! Map) return true;
final packages = lockfile['packages'];
if (packages is! Map) return true;
/// We consider an empty lockfile ready to get content-hashes.
if (packages.isEmpty) return true;
for (final package in packages.values) {
if (package is! Map) return true;
final descriptor = package['description'];
if (descriptor is! Map) return true;
if (descriptor['sha256'] != null) return true;
}
return false;
}