Warn about publishing in mixed mode (#2583)

diff --git a/lib/src/command/outdated.dart b/lib/src/command/outdated.dart
index acb9691..f04c11f 100644
--- a/lib/src/command/outdated.dart
+++ b/lib/src/command/outdated.dart
@@ -96,7 +96,7 @@
 
     final upgradablePubspec = includeDevDependencies
         ? rootPubspec
-        : _stripDevDependencies(rootPubspec);
+        : stripDevDependencies(rootPubspec);
 
     final resolvablePubspec = _stripVersionConstraints(upgradablePubspec);
 
@@ -104,8 +104,8 @@
     List<PackageId> resolvablePackages;
 
     await log.spinner('Resolving', () async {
-      upgradablePackages = await _tryResolve(upgradablePubspec);
-      resolvablePackages = await _tryResolve(resolvablePubspec);
+      upgradablePackages = await _tryResolve(upgradablePubspec, cache);
+      resolvablePackages = await _tryResolve(resolvablePubspec, cache);
     }, condition: _shouldShowSpinner);
 
     // This list will be empty if there is no lock file.
@@ -313,23 +313,23 @@
 
     return nonDevDependencies;
   }
+}
 
-  /// Try to solve [pubspec] return [PackageId]'s in the resolution or `null`.
-  Future<List<PackageId>> _tryResolve(Pubspec pubspec) async {
-    try {
-      return (await resolveVersions(
-        SolveType.UPGRADE,
-        cache,
-        Package.inMemory(pubspec),
-      ))
-          .packages;
-    } on SolveFailure {
-      return [];
-    }
+/// Try to solve [pubspec] return [PackageId]'s in the resolution or `null`.
+Future<List<PackageId>> _tryResolve(Pubspec pubspec, SystemCache cache) async {
+  try {
+    return (await resolveVersions(
+      SolveType.UPGRADE,
+      cache,
+      Package.inMemory(pubspec),
+    ))
+        .packages;
+  } on SolveFailure {
+    return [];
   }
 }
 
-Pubspec _stripDevDependencies(Pubspec original) {
+Pubspec stripDevDependencies(Pubspec original) {
   return Pubspec(
     original.name,
     version: original.version,
@@ -685,7 +685,7 @@
             if (versionDetails != null) {
               final nullSafety = nullSafetyMap[versionDetails._id];
 
-              switch (nullSafety) {
+              switch (nullSafety.compliance) {
                 case NullSafetyCompliance.analysisFailed:
                   color = color = log.gray;
                   prefix = '?';
@@ -698,7 +698,7 @@
                   asDesired = true;
                   break;
                 case NullSafetyCompliance.notCompliant:
-                case NullSafetyCompliance.apiOnly:
+                case NullSafetyCompliance.mixed:
                   color = log.red;
                   prefix = '✗';
                   nullSafetyJson = false;
diff --git a/lib/src/language_version.dart b/lib/src/language_version.dart
new file mode 100644
index 0000000..1c99bb3
--- /dev/null
+++ b/lib/src/language_version.dart
@@ -0,0 +1,55 @@
+// Copyright (c) 2020, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'package:analyzer/dart/ast/token.dart';
+import 'package:pub_semver/pub_semver.dart';
+
+/// A Dart language version as defined by
+/// https://github.com/dart-lang/language/blob/master/accepted/future-releases/language-versioning/feature-specification.md
+class LanguageVersion implements Comparable<LanguageVersion> {
+  final int major;
+  final int minor;
+
+  const LanguageVersion(this.major, this.minor);
+
+  /// The language version implied by a Dart sdk version.
+  factory LanguageVersion.fromVersion(Version version) =>
+      LanguageVersion(version.major, version.minor);
+
+  /// The language version implied by a Dart sdk version range.
+  ///
+  /// Throws if the versionRange has no lower bound.
+  factory LanguageVersion.fromVersionRange(VersionRange range) {
+    final min = range.min;
+    if (min == null) {
+      // TODO(sigurdm): is this right?
+      throw ArgumentError(
+          'Version range with no lower bound does not imply a language version');
+    }
+    return LanguageVersion(min.major, min.minor);
+  }
+
+  /// The language version implied by a Dart sdk version.
+  factory LanguageVersion.fromLanguageVersionToken(
+          LanguageVersionToken version) =>
+      LanguageVersion(version.major, version.minor);
+
+  bool get supportsNullSafety => this >= firstVersionWithNullSafety;
+
+  @override
+  int compareTo(LanguageVersion other) {
+    if (major != other.major) return major.compareTo(other.major);
+    return minor.compareTo(other.minor);
+  }
+
+  bool operator <(LanguageVersion other) => compareTo(other) < 0;
+  bool operator >(LanguageVersion other) => compareTo(other) > 0;
+  bool operator <=(LanguageVersion other) => compareTo(other) <= 0;
+  bool operator >=(LanguageVersion other) => compareTo(other) >= 0;
+
+  static const firstVersionWithNullSafety = LanguageVersion(2, 10);
+
+  @override
+  String toString() => '$major.$minor';
+}
diff --git a/lib/src/null_safety_analysis.dart b/lib/src/null_safety_analysis.dart
index 9939aa5..976ab18 100644
--- a/lib/src/null_safety_analysis.dart
+++ b/lib/src/null_safety_analysis.dart
@@ -6,12 +6,15 @@
 
 import 'package:analyzer/dart/analysis/context_builder.dart';
 import 'package:analyzer/dart/analysis/context_locator.dart';
+import 'package:analyzer/dart/ast/token.dart';
 import 'package:cli_util/cli_util.dart';
+import 'package:source_span/source_span.dart';
 
-import 'package:pub_semver/pub_semver.dart';
 import 'package:path/path.dart' as path;
+import 'package:yaml/yaml.dart';
 
 import 'io.dart';
+import 'language_version.dart';
 import 'package.dart';
 import 'package_name.dart';
 import 'pubspec.dart';
@@ -26,7 +29,7 @@
 
   /// This package opted into null safety, but some file or dependency is not
   /// opted in.
-  apiOnly,
+  mixed,
 
   /// This package did not opt-in to null safety yet.
   notCompliant,
@@ -48,9 +51,8 @@
   /// Furthermore by awaiting the Future stored here, we avoid race-conditions
   /// from downloading the same package-version into [_systemCache]
   /// simultaneously when doing concurrent analyses.
-  final Map<PackageId, Future<NullSafetyCompliance>>
+  final Map<PackageId, Future<NullSafetyAnalysisResult>>
       _packageInternallyGoodCache = {};
-  static final _firstVersionWithNullSafety = Version.parse('2.10.0');
 
   NullSafetyAnalysis(SystemCache systemCache) : _systemCache = systemCache;
 
@@ -67,7 +69,7 @@
   ///
   /// If [packageId] is a relative path dependency [containingPath] must be
   /// provided with an absolute path to resolve it against.
-  Future<NullSafetyCompliance> nullSafetyCompliance(PackageId packageId,
+  Future<NullSafetyAnalysisResult> nullSafetyCompliance(PackageId packageId,
       {String containingPath}) async {
     // A space in the name prevents clashes with other package names.
     final rootName = '${packageId.name} importer';
@@ -86,6 +88,21 @@
         },
         sources: _systemCache.sources));
 
+    final rootPubspec =
+        await packageId.source.bind(_systemCache).describe(packageId);
+    final rootLanguageVersion = rootPubspec.languageVersion;
+    if (!rootLanguageVersion.supportsNullSafety) {
+      final span =
+          _tryGetSpanFromYamlMap(rootPubspec.fields['environment'], 'sdk');
+      final where = span == null
+          ? 'in the sdk constraint in the enviroment key in pubspec.yaml.'
+          : 'in pubspec.yaml: \n${span.highlight()}';
+      return NullSafetyAnalysisResult(
+        NullSafetyCompliance.notCompliant,
+        'Is not opting in to null safety $where',
+      );
+    }
+
     SolveResult result;
     try {
       result = await resolveVersions(
@@ -93,22 +110,30 @@
         _systemCache,
         root,
       );
-    } on SolveFailure {
-      return NullSafetyCompliance.analysisFailed;
+    } on SolveFailure catch (e) {
+      return NullSafetyAnalysisResult(NullSafetyCompliance.analysisFailed,
+          'Could not resolve constraints: $e');
     }
 
-    var allPackagesGood = true;
+    NullSafetyAnalysisResult firstBadPackage;
     for (final dependencyId in result.packages) {
       if (dependencyId.name == root.name) continue;
 
-      final packageInternallyGood =
+      final packageInternalAnalysis =
           await _packageInternallyGoodCache.putIfAbsent(dependencyId, () async {
         final boundSource = dependencyId.source.bind(_systemCache);
         final pubspec = await boundSource.describe(dependencyId);
-        final languageVersion = _languageVersion(pubspec);
-        if (languageVersion == null ||
-            languageVersion < _firstVersionWithNullSafety) {
-          return NullSafetyCompliance.notCompliant;
+        final languageVersion = pubspec.languageVersion;
+        if (languageVersion == null || !languageVersion.supportsNullSafety) {
+          final span =
+              _tryGetSpanFromYamlMap(pubspec.fields['environment'], 'sdk');
+          final where = span == null
+              ? 'in the sdk constraint in the enviroment key in its pubspec.yaml.'
+              : 'in its pubspec.yaml:\n${span.highlight()}';
+          return NullSafetyAnalysisResult(
+            NullSafetyCompliance.notCompliant,
+            'package:${dependencyId.name} is not opted into null safety $where',
+          );
         }
 
         if (boundSource is CachedSource) {
@@ -131,50 +156,68 @@
           for (final file in listDir(libDir,
               recursive: true, includeDirs: false, includeHidden: true)) {
             if (file.endsWith('.dart')) {
+              final fileUrl =
+                  'package:${dependencyId.name}/${path.relative(file, from: libDir)}';
               final unitResult =
                   analysisSession.getParsedUnit(path.normalize(file));
               if (unitResult == null || unitResult.errors.isNotEmpty) {
-                return NullSafetyCompliance.analysisFailed;
+                return NullSafetyAnalysisResult(
+                    NullSafetyCompliance.analysisFailed,
+                    'Could not analyze $fileUrl.');
               }
               if (unitResult.isPart) continue;
               final languageVersionToken = unitResult.unit.languageVersionToken;
               if (languageVersionToken == null) continue;
-              if (Version(languageVersionToken.major,
-                      languageVersionToken.minor, 0) <
-                  _firstVersionWithNullSafety) {
-                return NullSafetyCompliance.notCompliant;
+              final languageVersion = LanguageVersion.fromLanguageVersionToken(
+                  languageVersionToken);
+              if (!languageVersion.supportsNullSafety) {
+                final sourceFile =
+                    SourceFile.fromString(readTextFile(file), url: fileUrl);
+                final span = sourceFile.span(languageVersionToken.offset,
+                    languageVersionToken.offset + languageVersionToken.length);
+                return NullSafetyAnalysisResult(
+                    NullSafetyCompliance.notCompliant,
+                    '$fileUrl is opting out of null safety:\n${span.highlight()}');
               }
             }
           }
         }
-        return NullSafetyCompliance.compliant;
+        return NullSafetyAnalysisResult(NullSafetyCompliance.compliant, null);
       });
-      assert(packageInternallyGood != null);
-      if (packageInternallyGood == NullSafetyCompliance.analysisFailed) {
-        return NullSafetyCompliance.analysisFailed;
+      assert(packageInternalAnalysis != null);
+      if (packageInternalAnalysis.compliance ==
+          NullSafetyCompliance.analysisFailed) {
+        return packageInternalAnalysis;
       }
-      if (packageInternallyGood == NullSafetyCompliance.notCompliant) {
-        allPackagesGood = false;
+      if (packageInternalAnalysis.compliance ==
+          NullSafetyCompliance.notCompliant) {
+        firstBadPackage ??= packageInternalAnalysis;
       }
     }
-    if (allPackagesGood) return NullSafetyCompliance.compliant;
-    final rootLanguageVersion = _languageVersion(
-        await packageId.source.bind(_systemCache).describe(packageId));
-    if (rootLanguageVersion != null &&
-        rootLanguageVersion >= _firstVersionWithNullSafety) {
-      return NullSafetyCompliance.apiOnly;
-    }
-    return NullSafetyCompliance.notCompliant;
-  }
 
-  /// Returns the language version specified by the dart sdk
-  Version _languageVersion(Pubspec pubspec) {
-    final sdkConstraint = pubspec.sdkConstraints['dart'];
-    if (sdkConstraint is VersionRange) {
-      final rangeMin = sdkConstraint.min;
-      if (rangeMin == null) return null;
-      return Version(rangeMin.major, rangeMin.minor, 0);
+    if (firstBadPackage == null) {
+      return NullSafetyAnalysisResult(NullSafetyCompliance.compliant, null);
     }
-    return null;
+    if (firstBadPackage.compliance == NullSafetyCompliance.analysisFailed) {
+      return firstBadPackage;
+    }
+    return NullSafetyAnalysisResult(
+        NullSafetyCompliance.mixed, firstBadPackage.reason);
   }
 }
+
+class NullSafetyAnalysisResult {
+  final NullSafetyCompliance compliance;
+
+  /// `null` if compliance == [NullSafetyCompliance.compliant].
+  final String reason;
+
+  NullSafetyAnalysisResult(this.compliance, this.reason);
+}
+
+SourceSpan _tryGetSpanFromYamlMap(Object map, String key) {
+  if (map is YamlMap) {
+    return map.nodes[key]?.span;
+  }
+  return null;
+}
diff --git a/lib/src/pubspec.dart b/lib/src/pubspec.dart
index 8bcc3a9..b309e04 100644
--- a/lib/src/pubspec.dart
+++ b/lib/src/pubspec.dart
@@ -14,6 +14,7 @@
 import 'exceptions.dart';
 import 'feature.dart';
 import 'io.dart';
+import 'language_version.dart';
 import 'log.dart';
 import 'package_name.dart';
 import 'sdk.dart';
@@ -445,6 +446,17 @@
   bool get isEmpty =>
       name == null && version == Version.none && dependencies.isEmpty;
 
+  /// The language version implied by the sdk constraint.
+  ///
+  /// Given no or unbounded constraint we assume language version 1.0.
+  LanguageVersion get languageVersion {
+    final constraint = originalDartSdkConstraint;
+    if (constraint is VersionRange && constraint.min != null) {
+      return LanguageVersion.fromVersionRange(constraint);
+    }
+    return LanguageVersion(1, 0);
+  }
+
   /// Loads the pubspec for a package located in [packageDir].
   ///
   /// If [expectedName] is passed and the pubspec doesn't have a matching name
diff --git a/lib/src/validator.dart b/lib/src/validator.dart
index 770f68b..83f8df1 100644
--- a/lib/src/validator.dart
+++ b/lib/src/validator.dart
@@ -5,6 +5,7 @@
 import 'dart:async';
 
 import 'package:meta/meta.dart';
+import 'package:pub/src/validator/null_safety_mixed_mode.dart';
 import 'package:pub_semver/pub_semver.dart';
 
 import 'entrypoint.dart';
@@ -136,6 +137,7 @@
       FlutterPluginFormatValidator(entrypoint),
       LanguageVersionValidator(entrypoint),
       RelativeVersionNumberingValidator(entrypoint, serverUrl),
+      NullSafetyMixedModeValidator(entrypoint),
     ];
     if (packageSize != null) {
       validators.add(SizeValidator(entrypoint, packageSize));
diff --git a/lib/src/validator/language_version.dart b/lib/src/validator/language_version.dart
index f0fbdd3..e52a92a 100644
--- a/lib/src/validator/language_version.dart
+++ b/lib/src/validator/language_version.dart
@@ -6,11 +6,11 @@
 
 import 'package:analyzer/dart/ast/ast.dart';
 import 'package:path/path.dart' as p;
-import 'package:pub_semver/pub_semver.dart';
 import 'package:stack_trace/stack_trace.dart';
 
 import '../dart.dart';
 import '../entrypoint.dart';
+import '../language_version.dart';
 import '../log.dart' as log;
 import '../utils.dart';
 import '../validator.dart';
@@ -28,17 +28,8 @@
 
   @override
   Future validate() async {
-    final sdkConstraint = entrypoint.root.pubspec.originalDartSdkConstraint;
+    final declaredLanguageVersion = entrypoint.root.pubspec.languageVersion;
 
-    /// If the sdk constraint is not a `VersionRange` something is wrong, and
-    /// we cannot deduce the language version.
-    ///
-    /// This will hopefully be detected elsewhere.
-    ///
-    /// A single `Version` is also a `VersionRange`.
-    if (sdkConstraint is! VersionRange) return;
-
-    final packageSdkMinVersion = (sdkConstraint as VersionRange).min;
     for (final path in ['lib', 'bin']
         .map((path) => entrypoint.root.listFiles(beneath: path))
         .expand((files) => files)
@@ -53,18 +44,15 @@
         continue;
       }
 
-      final unitLanguageVersion = unit.languageVersionToken;
-      if (unitLanguageVersion != null) {
-        if (Version(unitLanguageVersion.major, unitLanguageVersion.minor, 0) >
-            packageSdkMinVersion) {
-          final packageLanguageVersionString =
-              '${packageSdkMinVersion.major}.${packageSdkMinVersion.minor}';
-          final unitLanguageVersionString =
-              '${unitLanguageVersion.major}.${unitLanguageVersion.minor}';
+      final unitLanguageVersionToken = unit.languageVersionToken;
+      if (unitLanguageVersionToken != null) {
+        final unitLanguageVersion =
+            LanguageVersion.fromLanguageVersionToken(unitLanguageVersionToken);
+        if (unitLanguageVersion > declaredLanguageVersion) {
           final relativePath = p.relative(path);
           errors.add('$relativePath is declaring language version '
-              '$unitLanguageVersionString that is newer than the SDK '
-              'constraint $packageLanguageVersionString declared in '
+              '$unitLanguageVersion that is newer than the SDK '
+              'constraint $declaredLanguageVersion declared in '
               '`pubspec.yaml`.');
         }
       }
diff --git a/lib/src/validator/null_safety_mixed_mode.dart b/lib/src/validator/null_safety_mixed_mode.dart
new file mode 100644
index 0000000..069b1c1
--- /dev/null
+++ b/lib/src/validator/null_safety_mixed_mode.dart
@@ -0,0 +1,56 @@
+// Copyright (c) 2020, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:async';
+
+import 'package:path/path.dart' as p;
+
+import '../entrypoint.dart';
+import '../null_safety_analysis.dart';
+import '../package_name.dart';
+import '../validator.dart';
+
+/// Gives a warning when publishing a new version, if this package opts into
+/// null safety, but any of the dependencies do not.
+class NullSafetyMixedModeValidator extends Validator {
+  static const String guideUrl = 'https://dart.dev/null-safety/migration-guide';
+
+  NullSafetyMixedModeValidator(Entrypoint entrypoint) : super(entrypoint);
+
+  @override
+  Future<void> validate() async {
+    final pubspec = entrypoint.root.pubspec;
+    final declaredLanguageVersion = pubspec.languageVersion;
+    if (!declaredLanguageVersion.supportsNullSafety) {
+      return;
+    }
+    final analysisResult = await NullSafetyAnalysis(entrypoint.cache)
+        .nullSafetyCompliance(PackageId(
+            entrypoint.root.name,
+            entrypoint.cache.sources.path,
+            entrypoint.root.version,
+            {'relative': false, 'path': p.absolute(entrypoint.root.dir)}));
+
+    if (analysisResult.compliance == NullSafetyCompliance.mixed) {
+      warnings.add('''
+This package is opting into null-safety, but a dependency or file is not.
+
+${analysisResult.reason}
+
+Note that by publishing with non-migrated dependencies your package may be
+broken at any time if one of your dependencies migrates without a breaking 
+change release. 
+
+We highly recommend that you wait until all of your dependencies have been 
+migrated before publishing.
+
+Run `pub outdated --mode=null-safety` for more information about the state of
+dependencies.
+
+See $guideUrl
+for more information about migrating.
+''');
+    }
+  }
+}
diff --git a/lib/src/validator/relative_version_numbering.dart b/lib/src/validator/relative_version_numbering.dart
index 5e227fc..3d69038 100644
--- a/lib/src/validator/relative_version_numbering.dart
+++ b/lib/src/validator/relative_version_numbering.dart
@@ -8,6 +8,7 @@
 
 import '../entrypoint.dart';
 import '../exceptions.dart';
+import '../language_version.dart';
 import '../package_name.dart';
 import '../pubspec.dart';
 import '../validator.dart';
@@ -76,11 +77,6 @@
 
     if (constraintMin == null) return false;
 
-    final languageVersion =
-        Version(constraintMin.major, constraintMin.minor, 0);
-
-    return languageVersion >= _firstVersionSupportingNullSafety;
+    return LanguageVersion.fromVersionRange(sdkConstraint).supportsNullSafety;
   }
-
-  static final _firstVersionSupportingNullSafety = Version.parse('2.10.0');
 }
diff --git a/test/outdated/goldens/circular_dependencies.txt b/test/outdated/goldens/circular_dependencies.txt
index 9a521c2..6bbc4e7 100644
--- a/test/outdated/goldens/circular_dependencies.txt
+++ b/test/outdated/goldens/circular_dependencies.txt
@@ -85,7 +85,7 @@
 Showing packages where the current version doesn't fully support null safety.
 
 Dependencies  Current  Upgradable  Resolvable  Latest  
-foo           ?1.2.3   ?1.3.0      ?1.3.0      ?1.3.0  
+foo           ✗1.2.3   ✗1.3.0      ✗1.3.0      ✗1.3.0  
 
 dev_dependencies: all fully support null safety
 
@@ -103,19 +103,19 @@
       "package": "foo",
       "current": {
         "version": "1.2.3",
-        "nullSafety": null
+        "nullSafety": false
       },
       "upgradable": {
         "version": "1.3.0",
-        "nullSafety": null
+        "nullSafety": false
       },
       "resolvable": {
         "version": "1.3.0",
-        "nullSafety": null
+        "nullSafety": false
       },
       "latest": {
         "version": "1.3.0",
-        "nullSafety": null
+        "nullSafety": false
       }
     }
   ]
diff --git a/test/outdated/goldens/dependency_overrides_no_solution.txt b/test/outdated/goldens/dependency_overrides_no_solution.txt
index cea1ca0..f8ec65a 100644
--- a/test/outdated/goldens/dependency_overrides_no_solution.txt
+++ b/test/outdated/goldens/dependency_overrides_no_solution.txt
@@ -111,8 +111,8 @@
 Showing packages where the current version doesn't fully support null safety.
 
 Dependencies  Current              Upgradable           Resolvable           Latest  
-bar           ?1.0.0 (overridden)  ?1.0.0 (overridden)  ?1.0.0 (overridden)  ?2.0.0  
-foo           ?1.0.0 (overridden)  ?1.0.0 (overridden)  ?1.0.0 (overridden)  ?2.0.0  
+bar           ✗1.0.0 (overridden)  ✗1.0.0 (overridden)  ✗1.0.0 (overridden)  ✗2.0.0  
+foo           ✗1.0.0 (overridden)  ✗1.0.0 (overridden)  ✗1.0.0 (overridden)  ✗2.0.0  
 
 dev_dependencies: all fully support null safety
 
@@ -131,21 +131,21 @@
       "current": {
         "version": "1.0.0",
         "overridden": true,
-        "nullSafety": null
+        "nullSafety": false
       },
       "upgradable": {
         "version": "1.0.0",
         "overridden": true,
-        "nullSafety": null
+        "nullSafety": false
       },
       "resolvable": {
         "version": "1.0.0",
         "overridden": true,
-        "nullSafety": null
+        "nullSafety": false
       },
       "latest": {
         "version": "2.0.0",
-        "nullSafety": null
+        "nullSafety": false
       }
     },
     {
@@ -153,21 +153,21 @@
       "current": {
         "version": "1.0.0",
         "overridden": true,
-        "nullSafety": null
+        "nullSafety": false
       },
       "upgradable": {
         "version": "1.0.0",
         "overridden": true,
-        "nullSafety": null
+        "nullSafety": false
       },
       "resolvable": {
         "version": "1.0.0",
         "overridden": true,
-        "nullSafety": null
+        "nullSafety": false
       },
       "latest": {
         "version": "2.0.0",
-        "nullSafety": null
+        "nullSafety": false
       }
     }
   ]
diff --git a/test/outdated/goldens/mutually_incompatible.txt b/test/outdated/goldens/mutually_incompatible.txt
index 89ba210..13febb5 100644
--- a/test/outdated/goldens/mutually_incompatible.txt
+++ b/test/outdated/goldens/mutually_incompatible.txt
@@ -105,8 +105,8 @@
 Showing packages where the current version doesn't fully support null safety.
 
 Dependencies  Current  Upgradable  Resolvable  Latest  
-bar           ✗1.0.0   ✗1.0.0      ✗1.0.0      ?2.0.0  
-foo           ✗1.0.0   ✗1.0.0      ✗1.0.0      ?2.0.0  
+bar           ✗1.0.0   ✗1.0.0      ✗1.0.0      ✗2.0.0  
+foo           ✗1.0.0   ✗1.0.0      ✗1.0.0      ✗2.0.0  
 
 dev_dependencies: all fully support null safety
 
@@ -136,7 +136,7 @@
       },
       "latest": {
         "version": "2.0.0",
-        "nullSafety": null
+        "nullSafety": false
       }
     },
     {
@@ -155,7 +155,7 @@
       },
       "latest": {
         "version": "2.0.0",
-        "nullSafety": null
+        "nullSafety": false
       }
     }
   ]
diff --git a/test/outdated/outdated_test.dart b/test/outdated/outdated_test.dart
index 1acc869..3795ad5 100644
--- a/test/outdated/outdated_test.dart
+++ b/test/outdated/outdated_test.dart
@@ -196,50 +196,50 @@
         ..serve('foo', '1.0.0', deps: {
           'bar': '^1.0.0'
         }, pubspec: {
-          'environment': {'sdk': '>=2.9.0 < 3.0,0'}
+          'environment': {'sdk': '>=2.9.0 < 3.0.0'}
         })
         ..serve('bar', '1.0.0', pubspec: {
-          'environment': {'sdk': '>=2.9.0 < 3.0,0'}
+          'environment': {'sdk': '>=2.9.0 < 3.0.0'}
         })
         ..serve('foo', '2.0.0', deps: {
           'bar': '^1.0.0'
         }, pubspec: {
-          'environment': {'sdk': '>=2.10.0 < 3.0,0'}
+          'environment': {'sdk': '>=2.10.0 < 3.0.0'}
         })
         ..serve('bar', '2.0.0', pubspec: {
-          'environment': {'sdk': '>=2.11.0 < 3.0,0'}
+          'environment': {'sdk': '>=2.11.0 < 3.0.0'}
         })
         ..serve('file_opts_out', '1.0.0', pubspec: {
-          'environment': {'sdk': '>=2.10.0 < 3.0,0'},
+          'environment': {'sdk': '>=2.10.0 < 3.0.0'},
         }, contents: [
           d.dir('lib', [d.file('main.dart', '// @dart = 2.9\n')])
         ])
         ..serve('file_opts_out', '2.0.0', pubspec: {
-          'environment': {'sdk': '>=2.10.0 < 3.0,0'},
+          'environment': {'sdk': '>=2.10.0 < 3.0.0'},
         })
         ..serve('fails_analysis', '1.0.0', pubspec: {
-          'environment': {'sdk': '>=2.10.0 < 3.0,0'},
+          'environment': {'sdk': '>=2.10.0 < 3.0.0'},
         }, contents: [
           d.dir('lib', [d.file('main.dart', 'syntax error\n')])
         ])
         ..serve('fails_analysis', '2.0.0', pubspec: {
-          'environment': {'sdk': '>=2.10.0 < 3.0,0'},
+          'environment': {'sdk': '>=2.10.0 < 3.0.0'},
         })
         ..serve('file_in_dependency_opts_out', '1.0.0', deps: {
           'file_opts_out': '^1.0.0'
         }, pubspec: {
-          'environment': {'sdk': '>=2.10.0 < 3.0,0'},
+          'environment': {'sdk': '>=2.10.0 < 3.0.0'},
         })
         ..serve('file_in_dependency_opts_out', '2.0.0', pubspec: {
-          'environment': {'sdk': '>=2.10.0 < 3.0,0'},
+          'environment': {'sdk': '>=2.10.0 < 3.0.0'},
         })
         ..serve('fails_analysis_in_dependency', '1.0.0', deps: {
           'fails_analysis': '^1.0.0'
         }, pubspec: {
-          'environment': {'sdk': '>=2.10.0 < 3.0,0'},
+          'environment': {'sdk': '>=2.10.0 < 3.0.0'},
         })
         ..serve('fails_analysis_in_dependency', '2.0.0', pubspec: {
-          'environment': {'sdk': '>=2.10.0 < 3.0,0'},
+          'environment': {'sdk': '>=2.10.0 < 3.0.0'},
         }),
     );
     await pubGet(environment: {'_PUB_TEST_SDK_VERSION': '2.11.0'});
diff --git a/test/validator/null_safety_mixed_mode_test.dart b/test/validator/null_safety_mixed_mode_test.dart
new file mode 100644
index 0000000..15000a9
--- /dev/null
+++ b/test/validator/null_safety_mixed_mode_test.dart
@@ -0,0 +1,137 @@
+// Copyright (c) 2020, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'package:test/test.dart';
+
+import '../descriptor.dart' as d;
+import '../test_pub.dart';
+
+Future<void> expectValidation(error, int exitCode) async {
+  await runPub(
+      error: error,
+      args: ['publish', '--dry-run'],
+      environment: {'_PUB_TEST_SDK_VERSION': '2.10.0'},
+      workingDirectory: d.dir(appPath).io.path,
+      exitCode: exitCode);
+}
+
+Future<void> setup(
+    {String sdkConstraint,
+    Map dependencies = const {},
+    List<d.Descriptor> extraFiles = const []}) async {
+  await d.validPackage.create();
+  await d.dir(appPath, [
+    d.pubspec({
+      'name': 'test_pkg',
+      'description':
+          'A just long enough decription to fit the requirement of 60 characters',
+      'homepage': 'https://example.com/',
+      'version': '1.0.0',
+      'environment': {'sdk': sdkConstraint},
+      'dependencies': dependencies,
+    }),
+    ...extraFiles,
+  ]).create();
+
+  await pubGet(environment: {'_PUB_TEST_SDK_VERSION': '2.10.0'});
+}
+
+void main() {
+  group('should consider a package valid if it', () {
+    test('is not opting in to null-safety, but depends on package that is',
+        () async {
+      await servePackages(
+        (server) => server.serve(
+          'foo',
+          '0.0.1',
+          pubspec: {
+            'environment': {'sdk': '>=2.10.0<3.0.0'}
+          },
+        ),
+      );
+
+      await setup(
+          sdkConstraint: '>=2.9.0 <3.0.0', dependencies: {'foo': '^0.0.1'});
+      await expectValidation(contains('Package has 0 warnings.'), 0);
+    });
+    test('is opting in to null-safety and depends on package that is',
+        () async {
+      await servePackages(
+        (server) => server.serve(
+          'foo',
+          '0.0.1',
+          pubspec: {
+            'environment': {'sdk': '>=2.10.0<3.0.0'}
+          },
+        ),
+      );
+
+      await setup(
+          sdkConstraint: '>=2.10.0 <3.0.0', dependencies: {'foo': '^0.0.1'});
+      await expectValidation(contains('Package has 0 warnings.'), 0);
+    });
+  });
+
+  group('should consider a package invalid if it', () {
+    test('is opting in to null-safety, but depends on package that is not',
+        () async {
+      await servePackages(
+        (server) => server.serve(
+          'foo',
+          '0.0.1',
+          pubspec: {
+            'environment': {'sdk': '>=2.9.0<3.0.0'}
+          },
+        ),
+      );
+
+      await setup(
+          sdkConstraint: '>=2.10.0 <3.0.0', dependencies: {'foo': '^0.0.1'});
+      await expectValidation(
+          allOf(
+            contains(
+                'package:foo is not opted into null safety in its pubspec.yaml:'),
+            contains('Package has 1 warning.'),
+          ),
+          65);
+    });
+
+    test('is opting in to null-safety, but has file opting out', () async {
+      await setup(sdkConstraint: '>=2.10.0 <3.0.0', extraFiles: [
+        d.dir('lib', [d.file('a.dart', '// @dart = 2.9\n')])
+      ]);
+      await expectValidation(
+          allOf(
+            contains('package:test_pkg/a.dart is opting out of null safety:'),
+            contains('Package has 1 warning.'),
+          ),
+          65);
+    });
+
+    test(
+        'is opting in to null-safety, but depends on package has file opting out',
+        () async {
+      await servePackages(
+        (server) => server.serve('foo', '0.0.1', pubspec: {
+          'environment': {'sdk': '>=2.10.0<3.0.0'}
+        }, contents: [
+          d.dir('lib', [
+            d.file('foo.dart', '''
+// @dart = 2.9
+          ''')
+          ])
+        ]),
+      );
+
+      await setup(
+          sdkConstraint: '>=2.10.0 <3.0.0', dependencies: {'foo': '^0.0.1'});
+      await expectValidation(
+          allOf(
+            contains('package:foo/foo.dart is opting out of null safety:'),
+            contains('Package has 1 warning.'),
+          ),
+          65);
+    });
+  });
+}
diff --git a/test/validator/relative_version_numbering_test.dart b/test/validator/relative_version_numbering_test.dart
index a723706..7adcb1f 100644
--- a/test/validator/relative_version_numbering_test.dart
+++ b/test/validator/relative_version_numbering_test.dart
@@ -26,7 +26,6 @@
   ]).create();
 
   await pubGet(environment: {'_PUB_TEST_SDK_VERSION': '2.10.0'});
-  print(await d.file('.dart_tool/package_config.json').read());
 }
 
 void main() {