Smallest update in `dependency-services report` (#3761)

diff --git a/lib/src/command/dependency_services.dart b/lib/src/command/dependency_services.dart
index 44ae700..f1d8143 100644
--- a/lib/src/command/dependency_services.dart
+++ b/lib/src/command/dependency_services.dart
@@ -14,6 +14,7 @@
 import 'package:yaml_edit/yaml_edit.dart';
 
 import '../command.dart';
+import '../entrypoint.dart';
 import '../exceptions.dart';
 import '../io.dart';
 import '../lock_file.dart';
@@ -23,6 +24,7 @@
 import '../pubspec.dart';
 import '../pubspec_utils.dart';
 import '../solver.dart';
+import '../solver/version_solver.dart';
 import '../source/git.dart';
 import '../source/hosted.dart';
 import '../system_cache.dart';
@@ -51,133 +53,42 @@
 
   @override
   Future<void> runProtected() async {
+    final stdinString = await utf8.decodeStream(stdin);
+    final input = json.decode(stdinString.isEmpty ? '{}' : stdinString)
+        as Map<String, Object?>;
+    final additionalConstraints = _parseDisallowed(input, cache);
+    final targetPackageName = input['target'];
+    if (targetPackageName is! String?) {
+      throw FormatException('"target" should be a String.');
+    }
+
     final compatiblePubspec = stripDependencyOverrides(entrypoint.root.pubspec);
 
     final breakingPubspec = stripVersionBounds(compatiblePubspec);
 
-    final compatiblePackagesResult =
-        await _tryResolve(compatiblePubspec, cache);
+    final compatiblePackagesResult = await _tryResolve(
+      compatiblePubspec,
+      cache,
+      additionalConstraints: additionalConstraints,
+    );
 
-    final breakingPackagesResult = await _tryResolve(breakingPubspec, cache);
+    final breakingPackagesResult = await _tryResolve(
+      breakingPubspec,
+      cache,
+      additionalConstraints: additionalConstraints,
+    );
 
-    // 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 as PackageId).name,
-      );
-    }
-    currentPackages.remove(entrypoint.root.name);
+    final currentPackages = await _computeCurrentPackages(entrypoint, cache);
 
     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
-          ? stripVersionBounds(rootPubspec)
-          : Pubspec(
-              rootPubspec.name,
-              dependencies: rootPubspec.dependencies.values,
-              devDependencies: rootPubspec.devDependencies.values,
-              sdkConstraints: rootPubspec.sdkConstraints,
-            );
+    final targetPackage =
+        targetPackageName == null ? null : currentPackages[targetPackageName];
 
-      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()
-                    : _bumpConstraint(originalConstraint, 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()
-                        : _bumpConstraint(originalConstraint, 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) {
+    for (final package in targetPackage == null
+        ? currentPackages.values
+        : <PackageId>[targetPackage]) {
       final compatibleVersion = compatiblePackagesResult
           ?.firstWhereOrNull((element) => element.name == package.name);
       final multiBreakingVersion = breakingPackagesResult
@@ -202,6 +113,42 @@
         singleBreakingVersion = singleBreakingPackagesResult
             ?.firstWhereOrNull((element) => element.name == package.name);
       }
+      PackageId? smallestUpgrade;
+      if (additionalConstraints.any(
+        (c) => c.range.toRef() == package.toRef() && !c.range.allows(package),
+      )) {
+        // Current version disallowed by restrictions.
+        final atLeastCurrentPubspec = atLeastCurrent(
+          compatiblePubspec,
+          entrypoint.lockFile.packages.values.toList(),
+        );
+
+        final smallestUpgradeResult = await _tryResolve(
+          atLeastCurrentPubspec,
+          cache,
+          solveType: SolveType.downgrade,
+          additionalConstraints: additionalConstraints,
+        );
+
+        smallestUpgrade = smallestUpgradeResult
+            ?.firstWhereOrNull((element) => element.name == package.name);
+      }
+
+      Future<List<Object>> computeUpgradeSet(
+        PackageId? package,
+        _UpgradeType upgradeType,
+      ) async {
+        return await _computeUpgradeSet(
+          compatiblePubspec,
+          package,
+          entrypoint,
+          cache,
+          currentPackages: currentPackages,
+          upgradeType: upgradeType,
+          additionalConstraints: additionalConstraints,
+        );
+      }
+
       dependencies.add({
         'name': package.name,
         'version': package.versionOrHash(),
@@ -213,64 +160,32 @@
         'constraint':
             _constraintOf(compatiblePubspec, package.name)?.toString(),
         'compatible': await computeUpgradeSet(
-          compatiblePubspec,
           compatibleVersion,
-          upgradeType: _UpgradeType.compatible,
+          _UpgradeType.compatible,
         ),
         'singleBreaking': kind != 'transitive' && singleBreakingVersion == null
             ? []
             : await computeUpgradeSet(
-                compatiblePubspec,
                 singleBreakingVersion,
-                upgradeType: _UpgradeType.singleBreaking,
+                _UpgradeType.singleBreaking,
               ),
         'multiBreaking': kind != 'transitive' && multiBreakingVersion != null
             ? await computeUpgradeSet(
-                compatiblePubspec,
                 multiBreakingVersion,
-                upgradeType: _UpgradeType.multiBreaking,
+                _UpgradeType.multiBreaking,
               )
             : [],
+        if (smallestUpgrade != null)
+          'smallestUpdate': await computeUpgradeSet(
+            smallestUpgrade,
+            _UpgradeType.smallestUpdate,
+          ),
       });
     }
     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';
@@ -336,6 +251,9 @@
   /// Unlock any dependencies in pubspec.yaml needed for getting the
   /// latest resolvable version.
   multiBreaking,
+
+  /// Try to upgrade as little as possible.
+  smallestUpdate,
 }
 
 class DependencyServicesApplyCommand extends PubCommand {
@@ -715,6 +633,231 @@
   return false;
 }
 
+/// 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, {
+  SolveType solveType = SolveType.upgrade,
+  Iterable<ConstraintAndCause>? additionalConstraints,
+}) async {
+  final solveResult = await tryResolveVersions(
+    solveType,
+    cache,
+    Package.inMemory(pubspec),
+    additionalConstraints: additionalConstraints,
+  );
+
+  return solveResult?.packages;
+}
+
+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),
+  };
+}
+
+/// The packages in the current lockfile or resolved from current pubspec.yaml.
+/// Does not include the root package.
+Future<Map<String, PackageId>> _computeCurrentPackages(
+  Entrypoint entrypoint,
+  SystemCache cache,
+) async {
+  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 as PackageId).name,
+    );
+  }
+  currentPackages.remove(entrypoint.root.name);
+  return currentPackages;
+}
+
+Future<List<Object>> _computeUpgradeSet(
+  Pubspec rootPubspec,
+  PackageId? package,
+  Entrypoint entrypoint,
+  SystemCache cache, {
+  required Map<String, PackageId> currentPackages,
+  required _UpgradeType upgradeType,
+  required List<ConstraintAndCause> additionalConstraints,
+}) async {
+  if (package == null) return [];
+  final lockFile = entrypoint.lockFile;
+  final pubspec = (upgradeType == _UpgradeType.multiBreaking ||
+          upgradeType == _UpgradeType.smallestUpdate)
+      ? stripVersionBounds(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(
+    upgradeType == _UpgradeType.smallestUpdate
+        ? SolveType.downgrade
+        : SolveType.get,
+    cache,
+    Package.inMemory(pubspec),
+    lockFile: lockFile,
+    additionalConstraints: additionalConstraints,
+  );
+
+  // TODO(sigurdm): improve error messages.
+  if (resolution == null) {
+    return [];
+  }
+
+  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: entrypoint.root.dir),
+        'constraintBumped': originalConstraint == null
+            ? null
+            : upgradeType == _UpgradeType.compatible
+                ? originalConstraint.toString()
+                : _bumpConstraint(originalConstraint, 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()
+                    : _bumpConstraint(originalConstraint, p.version).toString(),
+        'previousVersion': currentPackage?.versionOrHash(),
+        'previousConstraint': originalConstraint?.toString(),
+        'previousSource': currentPackage == null
+            ? null
+            : _source(currentPackage, containingDir: entrypoint.root.dir),
+      };
+    }),
+    // 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: entrypoint.root.dir,
+          )
+        },
+  ];
+}
+
+List<ConstraintAndCause> _parseDisallowed(
+  Map<String, Object?> input,
+  SystemCache cache,
+) {
+  final disallowedList = input['disallowed'];
+  if (disallowedList == null) {
+    return [];
+  }
+  if (disallowedList is! List<Object?>) {
+    throw FormatException('Disallowed should be a list of maps');
+  }
+  final result = <ConstraintAndCause>[];
+  for (final disallowed in disallowedList) {
+    if (disallowed is! Map) {
+      throw FormatException('Disallowed should be a list of maps');
+    }
+    final name = disallowed['name'];
+    if (name is! String) {
+      throw FormatException('"name" should be a string.');
+    }
+    final url = disallowed['url'] ?? cache.hosted.defaultUrl;
+    if (url is! String) {
+      throw FormatException('"url" should be a string.');
+    }
+    final ref = PackageRef(
+      name,
+      HostedDescription(
+        name,
+        url,
+      ),
+    );
+    final constraints = disallowed['versions'];
+    if (constraints is! List) {
+      throw FormatException('"versions" should be a list.');
+    }
+    final reason = disallowed['reason'];
+    if (reason is! String?) {
+      throw FormatException('"reason", if present, should be a string.');
+    }
+    for (final entry in constraints) {
+      if (entry is! Map) {
+        throw FormatException(
+          'Each element of "versions" should be an object.',
+        );
+      }
+      final rangeString = entry['range'];
+      if (rangeString is! String) {
+        throw FormatException('"range" should be a string');
+      }
+      final range = VersionConstraint.parse(rangeString);
+      result.add(
+        ConstraintAndCause(
+          PackageRange(ref, VersionConstraint.any.difference(range)),
+          reason,
+        ),
+      );
+    }
+  }
+  return result;
+}
+
 /// `true` iff any of the packages described by the [lockfile] uses
 /// `https://pub.dev` as url.
 ///
diff --git a/lib/src/pubspec_utils.dart b/lib/src/pubspec_utils.dart
index 3f57976..5edf74a 100644
--- a/lib/src/pubspec_utils.dart
+++ b/lib/src/pubspec_utils.dart
@@ -2,6 +2,7 @@
 // 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 'package_name.dart';
@@ -84,6 +85,42 @@
   );
 }
 
+/// Returns a pubspec with the same dependencies as [original] but with all
+/// version constraints replaced by `>=c` where `c`, is the member of `current`
+/// that has same name as the dependency.
+Pubspec atLeastCurrent(Pubspec original, List<PackageId> current) {
+  List<PackageRange> fixBounds(
+    Map<String, PackageRange> constrained,
+  ) {
+    final result = <PackageRange>[];
+
+    for (final name in constrained.keys) {
+      final packageRange = constrained[name]!;
+      final currentVersion = current.firstWhereOrNull((id) => id.name == name);
+      if (currentVersion == null) {
+        result.add(packageRange);
+      } else {
+        result.add(
+          packageRange.toRef().withConstraint(
+                VersionRange(min: currentVersion.version, includeMin: true),
+              ),
+        );
+      }
+    }
+
+    return result;
+  }
+
+  return Pubspec(
+    original.name,
+    version: original.version,
+    sdkConstraints: original.sdkConstraints,
+    dependencies: fixBounds(original.dependencies),
+    devDependencies: fixBounds(original.devDependencies),
+    dependencyOverrides: original.dependencyOverrides.values,
+  );
+}
+
 /// Removes the upper bound of [constraint]. If [constraint] is the
 /// empty version constraint, [VersionConstraint.empty] will be returned.
 VersionConstraint stripUpperBound(VersionConstraint constraint) {
diff --git a/lib/src/solver.dart b/lib/src/solver.dart
index b8f68e7..71e2fcd 100644
--- a/lib/src/solver.dart
+++ b/lib/src/solver.dart
@@ -29,6 +29,9 @@
 /// If [unlock] is empty [SolveType.get] interprets this as lock everything,
 /// while [SolveType.upgrade] and [SolveType.downgrade] interprets an empty
 /// [unlock] as unlock everything.
+///
+/// [additionalConstraints] can contain a list of extra constraints for this
+/// resolution.
 Future<SolveResult> resolveVersions(
   SolveType type,
   SystemCache cache,
@@ -36,16 +39,21 @@
   LockFile? lockFile,
   Iterable<String> unlock = const [],
   Map<String, Version> sdkOverrides = const {},
+  Iterable<ConstraintAndCause>? additionalConstraints,
 }) {
   lockFile ??= LockFile.empty();
-  return VersionSolver(
+  final solver = VersionSolver(
     type,
     cache,
     root,
     lockFile,
     unlock,
     sdkOverrides: sdkOverrides,
-  ).solve();
+  );
+  if (additionalConstraints != null) {
+    solver.addConstraints(additionalConstraints);
+  }
+  return solver.solve();
 }
 
 /// Attempts to select the best concrete versions for all of the transitive
@@ -68,6 +76,7 @@
   Package root, {
   LockFile? lockFile,
   Iterable<String>? unlock,
+  Iterable<ConstraintAndCause>? additionalConstraints,
 }) async {
   try {
     return await resolveVersions(
@@ -76,6 +85,7 @@
       root,
       lockFile: lockFile,
       unlock: unlock ?? [],
+      additionalConstraints: additionalConstraints,
     );
   } on SolveFailure {
     return null;
diff --git a/lib/src/solver/incompatibility_cause.dart b/lib/src/solver/incompatibility_cause.dart
index 13047df..01b9dcf 100644
--- a/lib/src/solver/incompatibility_cause.dart
+++ b/lib/src/solver/incompatibility_cause.dart
@@ -147,3 +147,15 @@
   @override
   String? get hint => exception.hint;
 }
+
+/// The incompatibility represents a package-version that is not allowed to be
+/// used in the solve for some external reason.
+class PackageVersionForbiddenCause extends IncompatibilityCause {
+  /// The reason this package version was forbidden.
+  final String? reason;
+
+  PackageVersionForbiddenCause({this.reason});
+
+  @override
+  String? get hint => reason;
+}
diff --git a/lib/src/solver/version_solver.dart b/lib/src/solver/version_solver.dart
index afef413..5194e59 100644
--- a/lib/src/solver/version_solver.dart
+++ b/lib/src/solver/version_solver.dart
@@ -92,6 +92,18 @@
         _dependencyOverrides = _root.dependencyOverrides,
         _unlock = {...unlock};
 
+  /// Prime the solver with [constraints].
+  void addConstraints(Iterable<ConstraintAndCause> constraints) {
+    for (final constraint in constraints) {
+      _addIncompatibility(
+        Incompatibility(
+          [Term(constraint.range, false)],
+          PackageVersionForbiddenCause(reason: constraint.cause),
+        ),
+      );
+    }
+  }
+
   /// Finds a set of dependencies that match the root package's constraints, or
   /// throws an error if no such set is available.
   Future<SolveResult> solve() async {
@@ -581,3 +593,20 @@
     log.solver(prefixLines(message, prefix: '  ' * _solution.decisionLevel));
   }
 }
+
+// An additional constraint to a version resolution.
+class ConstraintAndCause {
+  /// Stated like constraints in the pubspec. (The constraint specifies those
+  /// versions that are allowed).
+  ///
+  /// Meaning that to forbid a version you must do
+  /// `VersionConstraint.any.difference(version)`.
+  ///
+  /// Example:
+  /// `ConstraintAndCause(packageRef, VersionConstraint.parse('> 1.0.0'))`
+  /// To require `packageRef` be greater than `1.0.0`.
+  final PackageRange range;
+  final String? cause;
+
+  ConstraintAndCause(this.range, this.cause);
+}
diff --git a/test/dependency_services/dependency_services_test.dart b/test/dependency_services/dependency_services_test.dart
index 4dbef36..f372701 100644
--- a/test/dependency_services/dependency_services_test.dart
+++ b/test/dependency_services/dependency_services_test.dart
@@ -46,7 +46,7 @@
   /// Returns the stdout.
   Future<String> runDependencyServices(
     List<String> args, {
-    String? stdin,
+    String stdin = '',
   }) async {
     final buffer = StringBuffer();
     buffer.writeln('## Section ${args.join(' ')}');
@@ -64,16 +64,15 @@
       },
       workingDirectory: p.join(d.sandbox, appPath),
     );
-    if (stdin != null) {
-      process.stdin.write(stdin);
-      await process.stdin.flush();
-      await process.stdin.close();
-    }
+    process.stdin.write(stdin);
+    await process.stdin.flush();
+    await process.stdin.close();
+
     final outLines = outputLines(process.stdout);
     final errLines = outputLines(process.stderr);
     final exitCode = await process.exitCode;
 
-    final pipe = stdin == null ? '' : ' echo ${escapeShellArgument(stdin)} |';
+    final pipe = ' echo ${filterUnstableText(escapeShellArgument(stdin))} |';
     buffer.writeln(
       [
         '\$$pipe dependency_services ${args.map(escapeShellArgument).join(' ')}',
@@ -113,6 +112,33 @@
   manifestAndLockfile(context);
 }
 
+Future<void> _reportWithForbidden(
+  GoldenTestContext context,
+  Map<String, List<String>> disallowedVersions, {
+  void Function(Map)? resultAssertions,
+  String? targetPackage,
+}) async {
+  manifestAndLockfile(context);
+  final input = json.encode({
+    'target': targetPackage,
+    'disallowed': [
+      for (final e in disallowedVersions.entries)
+        {
+          'name': e.key,
+          'url': globalServer.url,
+          'versions': e.value.map((d) => {'range': d}).toList()
+        }
+    ]
+  });
+  final report = await context.runDependencyServices(['report'], stdin: input);
+  if (resultAssertions != null) {
+    resultAssertions(json.decode(report) as Map);
+  }
+
+  // await context.runDependencyServices(['apply'], stdin: input);
+  manifestAndLockfile(context);
+}
+
 Future<void> main() async {
   setUpAll(() async {
     final tempDir = Directory.systemTemp.createTempSync();
@@ -161,6 +187,39 @@
     );
   });
 
+  testWithGolden('Ignoring version', (context) async {
+    final server = await servePackages();
+    server.serve('foo', '1.0.0');
+
+    await d.dir(appPath, [
+      d.pubspec({
+        'name': 'app',
+        'dependencies': {
+          'foo': '^1.0.0',
+        },
+      })
+    ]).create();
+    await pubGet();
+
+    server.serve('foo', '1.0.1'); // should get this.
+    server.serve('foo', '1.0.2'); // ignored
+    server.serve('foo', '1.0.3', deps: {'transitive': '1.0.0'});
+    server.serve('transitive', '1.0.0'); // ignored
+    await _reportWithForbidden(
+      context,
+      {
+        'foo': ['1.0.2'],
+        'transitive': ['1.0.0'],
+      },
+      resultAssertions: (report) {
+        expect(
+          findChangeVersion(report, 'compatible', 'foo'),
+          '1.0.1',
+        );
+      },
+    );
+  });
+
   testWithGolden('No pubspec.lock', (context) async {
     final server = (await servePackages())
       ..serve('foo', '1.2.3', deps: {'transitive': '^1.0.0'})
@@ -465,6 +524,110 @@
       },
     );
   });
+
+  testWithGolden('Finds smallest possible upgrade', (context) async {
+    final server = await servePackages();
+    server.serve('foo', '1.1.1'); // This version will be disallowed.
+
+    await d.appDir(dependencies: {'foo': '^1.0.0'}).create();
+    await pubGet();
+    server.serve(
+      'foo',
+      '1.0.0',
+    ); // We don't want the downgrade to go below the current.
+
+    server.serve(
+      'foo',
+      '1.1.2',
+    ); // This will also be disallowed, a minimal update should not find this.
+    server.serve('foo', '1.1.3'); // We would like this to be the new version.
+    server.serve('foo', '1.1.4'); // This version would not be a minimal update.
+
+    await _reportWithForbidden(
+      context,
+      {
+        'foo': ['1.1.1', '1.1.2']
+      },
+      targetPackage: 'foo',
+      resultAssertions: (r) {
+        expect(findChangeVersion(r, 'smallestUpdate', 'foo'), '1.1.3');
+      },
+    );
+  });
+
+  testWithGolden('Smallest possible upgrade can upgrade beyond breaking',
+      (context) async {
+    final server = await servePackages();
+    server.serve('foo', '1.1.1'); // This version will be disallowed.
+
+    await d.appDir(dependencies: {'foo': '^1.0.0'}).create();
+    await pubGet();
+
+    server.serve(
+      'foo',
+      '2.0.0',
+    ); // This will also be disallowed, a minimal update should not find this.
+    server.serve('foo', '2.0.1'); // We would like this to be the new version.
+    server.serve('foo', '2.0.2'); // This version would not be a minimal update.
+
+    await _reportWithForbidden(
+      context,
+      {
+        'foo': ['1.1.1', '2.0.0']
+      },
+      targetPackage: 'foo',
+      resultAssertions: (r) {
+        expect(findChangeVersion(r, 'smallestUpdate', 'foo'), '2.0.1');
+      },
+    );
+  });
+
+  testWithGolden(
+      'Smallest possible upgrade can upgrade other packages if needed',
+      (context) async {
+    final server = await servePackages();
+    server.serve('bar', '1.0.0');
+    server.serve('bar', '2.0.0');
+    server.serve('bar', '2.2.0');
+
+    server.serve(
+      'foo',
+      '1.1.1',
+      deps: {'bar': '^1.0.0'},
+    ); // This version will be disallowed.
+
+    await d.appDir(dependencies: {'foo': '^1.0.0', 'bar': '^1.0.0'}).create();
+    await pubGet();
+
+    server.serve(
+      'foo',
+      '2.0.0',
+      deps: {'bar': '^2.0.0'},
+    ); // This will also be disallowed, a minimal update should not find this.
+    server.serve(
+      'foo',
+      '2.0.1',
+      deps: {'bar': '^2.0.0'},
+    ); // We would like this to be the new version.
+    server.serve(
+      'foo',
+      '2.0.2',
+      deps: {'bar': '^2.0.0'},
+    ); // This version would not be a minimal update.
+
+    await _reportWithForbidden(
+      context,
+      {
+        'foo': ['1.1.1', '2.0.0'],
+        'bar': ['2.0.0']
+      },
+      targetPackage: 'foo',
+      resultAssertions: (r) {
+        expect(findChangeVersion(r, 'smallestUpdate', 'foo'), '2.0.1');
+        expect(findChangeVersion(r, 'smallestUpdate', 'bar'), '2.2.0');
+      },
+    );
+  });
 }
 
 dynamic findChangeVersion(dynamic json, String updateType, String name) {
diff --git a/test/testdata/goldens/dependency_services/dependency_services_test/Adding transitive.txt b/test/testdata/goldens/dependency_services/dependency_services_test/Adding transitive.txt
index 9c04c2f..e0533ae 100644
--- a/test/testdata/goldens/dependency_services/dependency_services_test/Adding transitive.txt
+++ b/test/testdata/goldens/dependency_services/dependency_services_test/Adding transitive.txt
@@ -19,7 +19,7 @@
 -------------------------------- END OF OUTPUT ---------------------------------
 
 ## Section list
-$ dependency_services list
+$ echo '' | dependency_services list
 {
   "dependencies": [
     {
@@ -42,7 +42,7 @@
 -------------------------------- END OF OUTPUT ---------------------------------
 
 ## Section report
-$ dependency_services report
+$ echo '' | dependency_services report
 {
   "dependencies": [
     {
diff --git a/test/testdata/goldens/dependency_services/dependency_services_test/Can update a git package.txt b/test/testdata/goldens/dependency_services/dependency_services_test/Can update a git package.txt
index c34c46f..da10be4 100644
--- a/test/testdata/goldens/dependency_services/dependency_services_test/Can update a git package.txt
+++ b/test/testdata/goldens/dependency_services/dependency_services_test/Can update a git package.txt
@@ -29,7 +29,7 @@
 -------------------------------- END OF OUTPUT ---------------------------------
 
 ## Section list
-$ dependency_services list
+$ echo '' | dependency_services list
 {
   "dependencies": [
     {
@@ -68,7 +68,7 @@
 -------------------------------- END OF OUTPUT ---------------------------------
 
 ## Section report
-$ dependency_services report
+$ echo '' | dependency_services report
 {
   "dependencies": [
     {
diff --git a/test/testdata/goldens/dependency_services/dependency_services_test/Compatible.txt b/test/testdata/goldens/dependency_services/dependency_services_test/Compatible.txt
index 86e82b9..c4e816c 100644
--- a/test/testdata/goldens/dependency_services/dependency_services_test/Compatible.txt
+++ b/test/testdata/goldens/dependency_services/dependency_services_test/Compatible.txt
@@ -35,7 +35,7 @@
 -------------------------------- END OF OUTPUT ---------------------------------
 
 ## Section list
-$ dependency_services list
+$ echo '' | dependency_services list
 {
   "dependencies": [
     {
@@ -86,7 +86,7 @@
 -------------------------------- END OF OUTPUT ---------------------------------
 
 ## Section report
-$ dependency_services report
+$ echo '' | dependency_services report
 {
   "dependencies": [
     {
diff --git a/test/testdata/goldens/dependency_services/dependency_services_test/Finds smallest possible upgrade.txt b/test/testdata/goldens/dependency_services/dependency_services_test/Finds smallest possible upgrade.txt
new file mode 100644
index 0000000..7fcfaf4
--- /dev/null
+++ b/test/testdata/goldens/dependency_services/dependency_services_test/Finds smallest possible upgrade.txt
@@ -0,0 +1,172 @@
+# GENERATED BY: test/dependency_services/dependency_services_test.dart
+
+$ cat pubspec.yaml
+{"name":"myapp","dependencies":{"foo":"^1.0.0"},"environment":{"sdk":"^3.0.2"}}
+$ cat pubspec.lock
+# Generated by pub
+# See https://dart.dev/tools/pub/glossary#lockfile
+packages:
+  foo:
+    dependency: "direct main"
+    description:
+      name: foo
+      sha256: ee9afb23699244da9e40f5001a3600e529c3696f2b8696906fd43ea8e54e0457
+      url: "http://localhost:$PORT"
+    source: hosted
+    version: "1.1.1"
+sdks:
+  dart: ">=3.0.2 <4.0.0"
+-------------------------------- END OF OUTPUT ---------------------------------
+
+## Section report
+$ echo '{"target":"foo","disallowed":[{"name":"foo","url":"http://localhost:$PORT","versions":[{"range":"1.1.1"},{"range":"1.1.2"}]}]}' | dependency_services report
+{
+  "dependencies": [
+    {
+      "name": "foo",
+      "version": "1.1.1",
+      "kind": "direct",
+      "source": {
+        "type": "hosted",
+        "description": {
+          "name": "foo",
+          "url": "http://localhost:$PORT",
+          "sha256": "ee9afb23699244da9e40f5001a3600e529c3696f2b8696906fd43ea8e54e0457"
+        }
+      },
+      "latest": "1.1.4",
+      "constraint": "^1.0.0",
+      "compatible": [
+        {
+          "name": "foo",
+          "version": "1.1.4",
+          "kind": "direct",
+          "source": {
+            "type": "hosted",
+            "description": {
+              "name": "foo",
+              "url": "http://localhost:$PORT",
+              "sha256": "4e29148fe4f00dc3e48082c074a538bc10030ed519622ee350815d5b2fd96a6d"
+            }
+          },
+          "constraintBumped": "^1.0.0",
+          "constraintWidened": "^1.0.0",
+          "constraintBumpedIfNeeded": "^1.0.0",
+          "previousVersion": "1.1.1",
+          "previousConstraint": "^1.0.0",
+          "previousSource": {
+            "type": "hosted",
+            "description": {
+              "name": "foo",
+              "url": "http://localhost:$PORT",
+              "sha256": "ee9afb23699244da9e40f5001a3600e529c3696f2b8696906fd43ea8e54e0457"
+            }
+          }
+        }
+      ],
+      "singleBreaking": [
+        {
+          "name": "foo",
+          "version": "1.1.4",
+          "kind": "direct",
+          "source": {
+            "type": "hosted",
+            "description": {
+              "name": "foo",
+              "url": "http://localhost:$PORT",
+              "sha256": "4e29148fe4f00dc3e48082c074a538bc10030ed519622ee350815d5b2fd96a6d"
+            }
+          },
+          "constraintBumped": "^1.1.4",
+          "constraintWidened": "^1.0.0",
+          "constraintBumpedIfNeeded": "^1.0.0",
+          "previousVersion": "1.1.1",
+          "previousConstraint": "^1.0.0",
+          "previousSource": {
+            "type": "hosted",
+            "description": {
+              "name": "foo",
+              "url": "http://localhost:$PORT",
+              "sha256": "ee9afb23699244da9e40f5001a3600e529c3696f2b8696906fd43ea8e54e0457"
+            }
+          }
+        }
+      ],
+      "multiBreaking": [
+        {
+          "name": "foo",
+          "version": "1.1.4",
+          "kind": "direct",
+          "source": {
+            "type": "hosted",
+            "description": {
+              "name": "foo",
+              "url": "http://localhost:$PORT",
+              "sha256": "4e29148fe4f00dc3e48082c074a538bc10030ed519622ee350815d5b2fd96a6d"
+            }
+          },
+          "constraintBumped": "^1.1.4",
+          "constraintWidened": "^1.0.0",
+          "constraintBumpedIfNeeded": "^1.0.0",
+          "previousVersion": "1.1.1",
+          "previousConstraint": "^1.0.0",
+          "previousSource": {
+            "type": "hosted",
+            "description": {
+              "name": "foo",
+              "url": "http://localhost:$PORT",
+              "sha256": "ee9afb23699244da9e40f5001a3600e529c3696f2b8696906fd43ea8e54e0457"
+            }
+          }
+        }
+      ],
+      "smallestUpdate": [
+        {
+          "name": "foo",
+          "version": "1.1.3",
+          "kind": "direct",
+          "source": {
+            "type": "hosted",
+            "description": {
+              "name": "foo",
+              "url": "http://localhost:$PORT",
+              "sha256": "5efda06be6bc23558d40eeab6f92729c53e5018621e7bda9868d97dd613693b9"
+            }
+          },
+          "constraintBumped": "^1.1.3",
+          "constraintWidened": "^1.0.0",
+          "constraintBumpedIfNeeded": "^1.0.0",
+          "previousVersion": "1.1.1",
+          "previousConstraint": "^1.0.0",
+          "previousSource": {
+            "type": "hosted",
+            "description": {
+              "name": "foo",
+              "url": "http://localhost:$PORT",
+              "sha256": "ee9afb23699244da9e40f5001a3600e529c3696f2b8696906fd43ea8e54e0457"
+            }
+          }
+        }
+      ]
+    }
+  ]
+}
+
+-------------------------------- END OF OUTPUT ---------------------------------
+
+$ cat pubspec.yaml
+{"name":"myapp","dependencies":{"foo":"^1.0.0"},"environment":{"sdk":"^3.0.2"}}
+$ cat pubspec.lock
+# Generated by pub
+# See https://dart.dev/tools/pub/glossary#lockfile
+packages:
+  foo:
+    dependency: "direct main"
+    description:
+      name: foo
+      sha256: ee9afb23699244da9e40f5001a3600e529c3696f2b8696906fd43ea8e54e0457
+      url: "http://localhost:$PORT"
+    source: hosted
+    version: "1.1.1"
+sdks:
+  dart: ">=3.0.2 <4.0.0"
diff --git a/test/testdata/goldens/dependency_services/dependency_services_test/Ignoring version.txt b/test/testdata/goldens/dependency_services/dependency_services_test/Ignoring version.txt
new file mode 100644
index 0000000..1636fda
--- /dev/null
+++ b/test/testdata/goldens/dependency_services/dependency_services_test/Ignoring version.txt
@@ -0,0 +1,117 @@
+# GENERATED BY: test/dependency_services/dependency_services_test.dart
+
+$ cat pubspec.yaml
+{"name":"app","dependencies":{"foo":"^1.0.0"},"environment":{"sdk":"^3.0.2"}}
+$ cat pubspec.lock
+# Generated by pub
+# See https://dart.dev/tools/pub/glossary#lockfile
+packages:
+  foo:
+    dependency: "direct main"
+    description:
+      name: foo
+      sha256: "4a8df8c695623e81d90f663801ead4a5269b406599b43b90ad558561a6f09c59"
+      url: "http://localhost:$PORT"
+    source: hosted
+    version: "1.0.0"
+sdks:
+  dart: ">=3.0.2 <4.0.0"
+-------------------------------- END OF OUTPUT ---------------------------------
+
+## Section report
+$ echo '{"target":null,"disallowed":[{"name":"foo","url":"http://localhost:$PORT","versions":[{"range":"1.0.2"}]},{"name":"transitive","url":"http://localhost:$PORT","versions":[{"range":"1.0.0"}]}]}' | dependency_services report
+{
+  "dependencies": [
+    {
+      "name": "foo",
+      "version": "1.0.0",
+      "kind": "direct",
+      "source": {
+        "type": "hosted",
+        "description": {
+          "name": "foo",
+          "url": "http://localhost:$PORT",
+          "sha256": "4a8df8c695623e81d90f663801ead4a5269b406599b43b90ad558561a6f09c59"
+        }
+      },
+      "latest": "1.0.3",
+      "constraint": "^1.0.0",
+      "compatible": [
+        {
+          "name": "foo",
+          "version": "1.0.1",
+          "kind": "direct",
+          "source": {
+            "type": "hosted",
+            "description": {
+              "name": "foo",
+              "url": "http://localhost:$PORT",
+              "sha256": "7e5c9a856d9aaa086b9724210ae9017edd53d704d630fe0cae743abc5074a997"
+            }
+          },
+          "constraintBumped": "^1.0.0",
+          "constraintWidened": "^1.0.0",
+          "constraintBumpedIfNeeded": "^1.0.0",
+          "previousVersion": "1.0.0",
+          "previousConstraint": "^1.0.0",
+          "previousSource": {
+            "type": "hosted",
+            "description": {
+              "name": "foo",
+              "url": "http://localhost:$PORT",
+              "sha256": "4a8df8c695623e81d90f663801ead4a5269b406599b43b90ad558561a6f09c59"
+            }
+          }
+        }
+      ],
+      "singleBreaking": [],
+      "multiBreaking": [
+        {
+          "name": "foo",
+          "version": "1.0.1",
+          "kind": "direct",
+          "source": {
+            "type": "hosted",
+            "description": {
+              "name": "foo",
+              "url": "http://localhost:$PORT",
+              "sha256": "7e5c9a856d9aaa086b9724210ae9017edd53d704d630fe0cae743abc5074a997"
+            }
+          },
+          "constraintBumped": "^1.0.1",
+          "constraintWidened": "^1.0.0",
+          "constraintBumpedIfNeeded": "^1.0.0",
+          "previousVersion": "1.0.0",
+          "previousConstraint": "^1.0.0",
+          "previousSource": {
+            "type": "hosted",
+            "description": {
+              "name": "foo",
+              "url": "http://localhost:$PORT",
+              "sha256": "4a8df8c695623e81d90f663801ead4a5269b406599b43b90ad558561a6f09c59"
+            }
+          }
+        }
+      ]
+    }
+  ]
+}
+
+-------------------------------- END OF OUTPUT ---------------------------------
+
+$ cat pubspec.yaml
+{"name":"app","dependencies":{"foo":"^1.0.0"},"environment":{"sdk":"^3.0.2"}}
+$ cat pubspec.lock
+# Generated by pub
+# See https://dart.dev/tools/pub/glossary#lockfile
+packages:
+  foo:
+    dependency: "direct main"
+    description:
+      name: foo
+      sha256: "4a8df8c695623e81d90f663801ead4a5269b406599b43b90ad558561a6f09c59"
+      url: "http://localhost:$PORT"
+    source: hosted
+    version: "1.0.0"
+sdks:
+  dart: ">=3.0.2 <4.0.0"
diff --git a/test/testdata/goldens/dependency_services/dependency_services_test/No pubspec.lock.txt b/test/testdata/goldens/dependency_services/dependency_services_test/No pubspec.lock.txt
index 46d1a77..0d46baa 100644
--- a/test/testdata/goldens/dependency_services/dependency_services_test/No pubspec.lock.txt
+++ b/test/testdata/goldens/dependency_services/dependency_services_test/No pubspec.lock.txt
@@ -7,7 +7,7 @@
 -------------------------------- END OF OUTPUT ---------------------------------
 
 ## Section list
-$ dependency_services list
+$ echo '' | dependency_services list
 {
   "dependencies": [
     {
@@ -59,7 +59,7 @@
 -------------------------------- END OF OUTPUT ---------------------------------
 
 ## Section report
-$ dependency_services report
+$ echo '' | dependency_services report
 {
   "dependencies": [
     {
diff --git a/test/testdata/goldens/dependency_services/dependency_services_test/Preserves no content-hashes.txt b/test/testdata/goldens/dependency_services/dependency_services_test/Preserves no content-hashes.txt
index a0ffae6..61c69a2 100644
--- a/test/testdata/goldens/dependency_services/dependency_services_test/Preserves no content-hashes.txt
+++ b/test/testdata/goldens/dependency_services/dependency_services_test/Preserves no content-hashes.txt
@@ -32,7 +32,7 @@
 -------------------------------- END OF OUTPUT ---------------------------------
 
 ## Section list
-$ dependency_services list
+$ echo '' | dependency_services list
 {
   "dependencies": [
     {
@@ -80,7 +80,7 @@
 -------------------------------- END OF OUTPUT ---------------------------------
 
 ## Section report
-$ dependency_services report
+$ echo '' | dependency_services report
 {
   "dependencies": [
     {
diff --git a/test/testdata/goldens/dependency_services/dependency_services_test/Preserves pub.dartlang.org as hosted url.txt b/test/testdata/goldens/dependency_services/dependency_services_test/Preserves pub.dartlang.org as hosted url.txt
index 5a95570..2483450 100644
--- a/test/testdata/goldens/dependency_services/dependency_services_test/Preserves pub.dartlang.org as hosted url.txt
+++ b/test/testdata/goldens/dependency_services/dependency_services_test/Preserves pub.dartlang.org as hosted url.txt
@@ -27,7 +27,7 @@
 -------------------------------- END OF OUTPUT ---------------------------------
 
 ## Section list
-$ dependency_services list
+$ echo '' | dependency_services list
 {
   "dependencies": [
     {
@@ -64,7 +64,7 @@
 -------------------------------- END OF OUTPUT ---------------------------------
 
 ## Section report
-$ dependency_services report
+$ echo '' | dependency_services report
 {
   "dependencies": [
     {
diff --git a/test/testdata/goldens/dependency_services/dependency_services_test/Relative paths are allowed.txt b/test/testdata/goldens/dependency_services/dependency_services_test/Relative paths are allowed.txt
index 6ac254b..7560bdd 100644
--- a/test/testdata/goldens/dependency_services/dependency_services_test/Relative paths are allowed.txt
+++ b/test/testdata/goldens/dependency_services/dependency_services_test/Relative paths are allowed.txt
@@ -26,7 +26,7 @@
 -------------------------------- END OF OUTPUT ---------------------------------
 
 ## Section list
-$ dependency_services list
+$ echo '' | dependency_services list
 {
   "dependencies": [
     {
@@ -62,7 +62,7 @@
 -------------------------------- END OF OUTPUT ---------------------------------
 
 ## Section report
-$ dependency_services report
+$ echo '' | dependency_services report
 {
   "dependencies": [
     {
diff --git a/test/testdata/goldens/dependency_services/dependency_services_test/Removing transitive.txt b/test/testdata/goldens/dependency_services/dependency_services_test/Removing transitive.txt
index 097456a..3f3a887 100644
--- a/test/testdata/goldens/dependency_services/dependency_services_test/Removing transitive.txt
+++ b/test/testdata/goldens/dependency_services/dependency_services_test/Removing transitive.txt
@@ -27,7 +27,7 @@
 -------------------------------- END OF OUTPUT ---------------------------------
 
 ## Section list
-$ dependency_services list
+$ echo '' | dependency_services list
 {
   "dependencies": [
     {
@@ -64,7 +64,7 @@
 -------------------------------- END OF OUTPUT ---------------------------------
 
 ## Section report
-$ dependency_services report
+$ echo '' | dependency_services report
 {
   "dependencies": [
     {
diff --git a/test/testdata/goldens/dependency_services/dependency_services_test/Smallest possible upgrade can upgrade beyond breaking.txt b/test/testdata/goldens/dependency_services/dependency_services_test/Smallest possible upgrade can upgrade beyond breaking.txt
new file mode 100644
index 0000000..fdf4b1a
--- /dev/null
+++ b/test/testdata/goldens/dependency_services/dependency_services_test/Smallest possible upgrade can upgrade beyond breaking.txt
@@ -0,0 +1,145 @@
+# GENERATED BY: test/dependency_services/dependency_services_test.dart
+
+$ cat pubspec.yaml
+{"name":"myapp","dependencies":{"foo":"^1.0.0"},"environment":{"sdk":"^3.0.2"}}
+$ cat pubspec.lock
+# Generated by pub
+# See https://dart.dev/tools/pub/glossary#lockfile
+packages:
+  foo:
+    dependency: "direct main"
+    description:
+      name: foo
+      sha256: ee9afb23699244da9e40f5001a3600e529c3696f2b8696906fd43ea8e54e0457
+      url: "http://localhost:$PORT"
+    source: hosted
+    version: "1.1.1"
+sdks:
+  dart: ">=3.0.2 <4.0.0"
+-------------------------------- END OF OUTPUT ---------------------------------
+
+## Section report
+$ echo '{"target":"foo","disallowed":[{"name":"foo","url":"http://localhost:$PORT","versions":[{"range":"1.1.1"},{"range":"2.0.0"}]}]}' | dependency_services report
+{
+  "dependencies": [
+    {
+      "name": "foo",
+      "version": "1.1.1",
+      "kind": "direct",
+      "source": {
+        "type": "hosted",
+        "description": {
+          "name": "foo",
+          "url": "http://localhost:$PORT",
+          "sha256": "ee9afb23699244da9e40f5001a3600e529c3696f2b8696906fd43ea8e54e0457"
+        }
+      },
+      "latest": "2.0.2",
+      "constraint": "^1.0.0",
+      "compatible": [],
+      "singleBreaking": [
+        {
+          "name": "foo",
+          "version": "2.0.2",
+          "kind": "direct",
+          "source": {
+            "type": "hosted",
+            "description": {
+              "name": "foo",
+              "url": "http://localhost:$PORT",
+              "sha256": "3db0dc36e17a64a3f2ba5c2aa5871c056621886c9cc8464a0123642d3d68d272"
+            }
+          },
+          "constraintBumped": "^2.0.2",
+          "constraintWidened": ">=1.0.0 <3.0.0",
+          "constraintBumpedIfNeeded": "^2.0.2",
+          "previousVersion": "1.1.1",
+          "previousConstraint": "^1.0.0",
+          "previousSource": {
+            "type": "hosted",
+            "description": {
+              "name": "foo",
+              "url": "http://localhost:$PORT",
+              "sha256": "ee9afb23699244da9e40f5001a3600e529c3696f2b8696906fd43ea8e54e0457"
+            }
+          }
+        }
+      ],
+      "multiBreaking": [
+        {
+          "name": "foo",
+          "version": "2.0.2",
+          "kind": "direct",
+          "source": {
+            "type": "hosted",
+            "description": {
+              "name": "foo",
+              "url": "http://localhost:$PORT",
+              "sha256": "3db0dc36e17a64a3f2ba5c2aa5871c056621886c9cc8464a0123642d3d68d272"
+            }
+          },
+          "constraintBumped": "^2.0.2",
+          "constraintWidened": ">=1.0.0 <3.0.0",
+          "constraintBumpedIfNeeded": "^2.0.2",
+          "previousVersion": "1.1.1",
+          "previousConstraint": "^1.0.0",
+          "previousSource": {
+            "type": "hosted",
+            "description": {
+              "name": "foo",
+              "url": "http://localhost:$PORT",
+              "sha256": "ee9afb23699244da9e40f5001a3600e529c3696f2b8696906fd43ea8e54e0457"
+            }
+          }
+        }
+      ],
+      "smallestUpdate": [
+        {
+          "name": "foo",
+          "version": "2.0.1",
+          "kind": "direct",
+          "source": {
+            "type": "hosted",
+            "description": {
+              "name": "foo",
+              "url": "http://localhost:$PORT",
+              "sha256": "bcaaad4d1c92a89ff8dfb1ce146e11635fbce6219dc8992d5a5d8eaca0658fdd"
+            }
+          },
+          "constraintBumped": "^2.0.1",
+          "constraintWidened": ">=1.0.0 <3.0.0",
+          "constraintBumpedIfNeeded": "^2.0.1",
+          "previousVersion": "1.1.1",
+          "previousConstraint": "^1.0.0",
+          "previousSource": {
+            "type": "hosted",
+            "description": {
+              "name": "foo",
+              "url": "http://localhost:$PORT",
+              "sha256": "ee9afb23699244da9e40f5001a3600e529c3696f2b8696906fd43ea8e54e0457"
+            }
+          }
+        }
+      ]
+    }
+  ]
+}
+
+-------------------------------- END OF OUTPUT ---------------------------------
+
+$ cat pubspec.yaml
+{"name":"myapp","dependencies":{"foo":"^1.0.0"},"environment":{"sdk":"^3.0.2"}}
+$ cat pubspec.lock
+# Generated by pub
+# See https://dart.dev/tools/pub/glossary#lockfile
+packages:
+  foo:
+    dependency: "direct main"
+    description:
+      name: foo
+      sha256: ee9afb23699244da9e40f5001a3600e529c3696f2b8696906fd43ea8e54e0457
+      url: "http://localhost:$PORT"
+    source: hosted
+    version: "1.1.1"
+sdks:
+  dart: ">=3.0.2 <4.0.0"
diff --git a/test/testdata/goldens/dependency_services/dependency_services_test/Smallest possible upgrade can upgrade other packages if needed.txt b/test/testdata/goldens/dependency_services/dependency_services_test/Smallest possible upgrade can upgrade other packages if needed.txt
new file mode 100644
index 0000000..745d857
--- /dev/null
+++ b/test/testdata/goldens/dependency_services/dependency_services_test/Smallest possible upgrade can upgrade other packages if needed.txt
@@ -0,0 +1,186 @@
+# GENERATED BY: test/dependency_services/dependency_services_test.dart
+
+$ cat pubspec.yaml
+{"name":"myapp","dependencies":{"foo":"^1.0.0","bar":"^1.0.0"},"environment":{"sdk":"^3.0.2"}}
+$ cat pubspec.lock
+# Generated by pub
+# See https://dart.dev/tools/pub/glossary#lockfile
+packages:
+  bar:
+    dependency: "direct main"
+    description:
+      name: bar
+      sha256: "8cd4b5a00de63aa592f4240249affd87abf49de4281233870f22b30919f87d42"
+      url: "http://localhost:$PORT"
+    source: hosted
+    version: "1.0.0"
+  foo:
+    dependency: "direct main"
+    description:
+      name: foo
+      sha256: "6f20056a28b780546e364af618f5c357863660259f8df4431c7fd12ccb8b4372"
+      url: "http://localhost:$PORT"
+    source: hosted
+    version: "1.1.1"
+sdks:
+  dart: ">=3.0.2 <4.0.0"
+-------------------------------- END OF OUTPUT ---------------------------------
+
+## Section report
+$ echo '{"target":"foo","disallowed":[{"name":"foo","url":"http://localhost:$PORT","versions":[{"range":"1.1.1"},{"range":"2.0.0"}]},{"name":"bar","url":"http://localhost:$PORT","versions":[{"range":"2.0.0"}]}]}' | dependency_services report
+{
+  "dependencies": [
+    {
+      "name": "foo",
+      "version": "1.1.1",
+      "kind": "direct",
+      "source": {
+        "type": "hosted",
+        "description": {
+          "name": "foo",
+          "url": "http://localhost:$PORT",
+          "sha256": "6f20056a28b780546e364af618f5c357863660259f8df4431c7fd12ccb8b4372"
+        }
+      },
+      "latest": "2.0.2",
+      "constraint": "^1.0.0",
+      "compatible": [],
+      "singleBreaking": [],
+      "multiBreaking": [
+        {
+          "name": "foo",
+          "version": "2.0.2",
+          "kind": "direct",
+          "source": {
+            "type": "hosted",
+            "description": {
+              "name": "foo",
+              "url": "http://localhost:$PORT",
+              "sha256": "2fe8aa833d5a1c720c47cde975d63ff8cd1436da5a13b9ff413eb2b489bdf850"
+            }
+          },
+          "constraintBumped": "^2.0.2",
+          "constraintWidened": ">=1.0.0 <3.0.0",
+          "constraintBumpedIfNeeded": "^2.0.2",
+          "previousVersion": "1.1.1",
+          "previousConstraint": "^1.0.0",
+          "previousSource": {
+            "type": "hosted",
+            "description": {
+              "name": "foo",
+              "url": "http://localhost:$PORT",
+              "sha256": "6f20056a28b780546e364af618f5c357863660259f8df4431c7fd12ccb8b4372"
+            }
+          }
+        },
+        {
+          "name": "bar",
+          "version": "2.2.0",
+          "kind": "direct",
+          "source": {
+            "type": "hosted",
+            "description": {
+              "name": "bar",
+              "url": "http://localhost:$PORT",
+              "sha256": "3c51e3f45f9b993f18c64ab5c55e5f8149c98bac06b0c3e9100fef98385afe4f"
+            }
+          },
+          "constraintBumped": "^2.2.0",
+          "constraintWidened": ">=1.0.0 <3.0.0",
+          "constraintBumpedIfNeeded": "^2.2.0",
+          "previousVersion": "1.0.0",
+          "previousConstraint": "^1.0.0",
+          "previousSource": {
+            "type": "hosted",
+            "description": {
+              "name": "bar",
+              "url": "http://localhost:$PORT",
+              "sha256": "8cd4b5a00de63aa592f4240249affd87abf49de4281233870f22b30919f87d42"
+            }
+          }
+        }
+      ],
+      "smallestUpdate": [
+        {
+          "name": "foo",
+          "version": "2.0.1",
+          "kind": "direct",
+          "source": {
+            "type": "hosted",
+            "description": {
+              "name": "foo",
+              "url": "http://localhost:$PORT",
+              "sha256": "3fc65bb8d960eb60a8ff969ba138df09de120b4e05dbde7a45b7f77aba179237"
+            }
+          },
+          "constraintBumped": "^2.0.1",
+          "constraintWidened": ">=1.0.0 <3.0.0",
+          "constraintBumpedIfNeeded": "^2.0.1",
+          "previousVersion": "1.1.1",
+          "previousConstraint": "^1.0.0",
+          "previousSource": {
+            "type": "hosted",
+            "description": {
+              "name": "foo",
+              "url": "http://localhost:$PORT",
+              "sha256": "6f20056a28b780546e364af618f5c357863660259f8df4431c7fd12ccb8b4372"
+            }
+          }
+        },
+        {
+          "name": "bar",
+          "version": "2.2.0",
+          "kind": "direct",
+          "source": {
+            "type": "hosted",
+            "description": {
+              "name": "bar",
+              "url": "http://localhost:$PORT",
+              "sha256": "3c51e3f45f9b993f18c64ab5c55e5f8149c98bac06b0c3e9100fef98385afe4f"
+            }
+          },
+          "constraintBumped": "^2.2.0",
+          "constraintWidened": ">=1.0.0 <3.0.0",
+          "constraintBumpedIfNeeded": "^2.2.0",
+          "previousVersion": "1.0.0",
+          "previousConstraint": "^1.0.0",
+          "previousSource": {
+            "type": "hosted",
+            "description": {
+              "name": "bar",
+              "url": "http://localhost:$PORT",
+              "sha256": "8cd4b5a00de63aa592f4240249affd87abf49de4281233870f22b30919f87d42"
+            }
+          }
+        }
+      ]
+    }
+  ]
+}
+
+-------------------------------- END OF OUTPUT ---------------------------------
+
+$ cat pubspec.yaml
+{"name":"myapp","dependencies":{"foo":"^1.0.0","bar":"^1.0.0"},"environment":{"sdk":"^3.0.2"}}
+$ cat pubspec.lock
+# Generated by pub
+# See https://dart.dev/tools/pub/glossary#lockfile
+packages:
+  bar:
+    dependency: "direct main"
+    description:
+      name: bar
+      sha256: "8cd4b5a00de63aa592f4240249affd87abf49de4281233870f22b30919f87d42"
+      url: "http://localhost:$PORT"
+    source: hosted
+    version: "1.0.0"
+  foo:
+    dependency: "direct main"
+    description:
+      name: foo
+      sha256: "6f20056a28b780546e364af618f5c357863660259f8df4431c7fd12ccb8b4372"
+      url: "http://localhost:$PORT"
+    source: hosted
+    version: "1.1.1"
+sdks:
+  dart: ">=3.0.2 <4.0.0"
diff --git a/test/testdata/goldens/dependency_services/dependency_services_test/multibreaking.txt b/test/testdata/goldens/dependency_services/dependency_services_test/multibreaking.txt
index a65da00..1e63cb2 100644
--- a/test/testdata/goldens/dependency_services/dependency_services_test/multibreaking.txt
+++ b/test/testdata/goldens/dependency_services/dependency_services_test/multibreaking.txt
@@ -35,7 +35,7 @@
 -------------------------------- END OF OUTPUT ---------------------------------
 
 ## Section list
-$ dependency_services list
+$ echo '' | dependency_services list
 {
   "dependencies": [
     {
@@ -86,7 +86,7 @@
 -------------------------------- END OF OUTPUT ---------------------------------
 
 ## Section report
-$ dependency_services report
+$ echo '' | dependency_services report
 {
   "dependencies": [
     {