Merge package:pubspec_parse into the tools monorepo
diff --git a/pkgs/pubspec_parse/.github/dependabot.yml b/pkgs/pubspec_parse/.github/dependabot.yml
new file mode 100644
index 0000000..cde02ad
--- /dev/null
+++ b/pkgs/pubspec_parse/.github/dependabot.yml
@@ -0,0 +1,15 @@
+# Dependabot configuration file.
+# See https://docs.github.com/en/code-security/dependabot/dependabot-version-updates
+version: 2
+
+updates:
+  - package-ecosystem: github-actions
+    directory: /
+    schedule:
+      interval: monthly
+    labels:
+      - autosubmit
+    groups:
+      github-actions:
+        patterns:
+          - "*"
diff --git a/pkgs/pubspec_parse/.github/workflows/publish.yaml b/pkgs/pubspec_parse/.github/workflows/publish.yaml
new file mode 100644
index 0000000..27157a0
--- /dev/null
+++ b/pkgs/pubspec_parse/.github/workflows/publish.yaml
@@ -0,0 +1,17 @@
+# A CI configuration to auto-publish pub packages.
+
+name: Publish
+
+on:
+  pull_request:
+    branches: [ master ]
+  push:
+    tags: [ 'v[0-9]+.[0-9]+.[0-9]+' ]
+
+jobs:
+  publish:
+    if: ${{ github.repository_owner == 'dart-lang' }}
+    uses: dart-lang/ecosystem/.github/workflows/publish.yaml@main
+    permissions:
+      id-token: write # Required for authentication using OIDC
+      pull-requests: write # Required for writing the pull request note
diff --git a/pkgs/pubspec_parse/.github/workflows/test-package.yml b/pkgs/pubspec_parse/.github/workflows/test-package.yml
new file mode 100644
index 0000000..922d6c2
--- /dev/null
+++ b/pkgs/pubspec_parse/.github/workflows/test-package.yml
@@ -0,0 +1,60 @@
+name: Dart CI
+
+on:
+  # Run on PRs and pushes to the default branch.
+  push:
+    branches: [ master ]
+  pull_request:
+    branches: [ master ]
+  schedule:
+    - cron: "0 0 * * 0"
+
+env:
+  PUB_ENVIRONMENT: bot.github
+
+jobs:
+  # Check code formatting and static analysis on a single OS (linux)
+  # against Dart dev.
+  analyze:
+    runs-on: ubuntu-latest
+    strategy:
+      fail-fast: false
+      matrix:
+        sdk: [dev]
+    steps:
+      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      - uses: dart-lang/setup-dart@e630b99d28a3b71860378cafdc2a067c71107f94
+        with:
+          sdk: ${{ matrix.sdk }}
+      - id: install
+        name: Install dependencies
+        run: dart pub get
+      - name: Check formatting
+        run: dart format --output=none --set-exit-if-changed .
+        if: always() && steps.install.outcome == 'success'
+      - name: Analyze code
+        run: dart analyze --fatal-infos
+        if: always() && steps.install.outcome == 'success'
+
+  # Run tests on a matrix consisting of two dimensions:
+  # 1. OS: ubuntu-latest, (macos-latest, windows-latest)
+  # 2. release channel: dev
+  test:
+    needs: analyze
+    runs-on: ${{ matrix.os }}
+    strategy:
+      fail-fast: false
+      matrix:
+        os: [ubuntu-latest]
+        sdk: [3.2, dev]
+    steps:
+      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      - uses: dart-lang/setup-dart@e630b99d28a3b71860378cafdc2a067c71107f94
+        with:
+          sdk: ${{ matrix.sdk }}
+      - id: install
+        name: Install dependencies
+        run: dart pub get
+      - name: Run VM tests
+        run: dart test --platform vm --run-skipped
+        if: always() && steps.install.outcome == 'success'
diff --git a/pkgs/pubspec_parse/.gitignore b/pkgs/pubspec_parse/.gitignore
new file mode 100644
index 0000000..ec8eae3
--- /dev/null
+++ b/pkgs/pubspec_parse/.gitignore
@@ -0,0 +1,4 @@
+# Don’t commit the following directories created by pub.
+.dart_tool/
+.packages
+pubspec.lock
diff --git a/pkgs/pubspec_parse/CHANGELOG.md b/pkgs/pubspec_parse/CHANGELOG.md
new file mode 100644
index 0000000..251d4cc
--- /dev/null
+++ b/pkgs/pubspec_parse/CHANGELOG.md
@@ -0,0 +1,103 @@
+## 1.4.0-wip
+
+- Require Dart 3.2
+- Seal the `Dependency` class.
+- Set `Pubspec.environment` to non-nullable.
+- Remove deprecated package_api_docs rule
+
+## 1.3.0
+
+- Require Dart 3.0
+- Added support for `ignored_advisories` field.
+- Added structural equality for `Dependency` subclasses and `HostedDetails`.
+
+## 1.2.3
+
+- Added topics to `pubspec.yaml`.
+
+## 1.2.2
+
+- Require Dart SDK >= 2.18.0
+- Required `json_annotation: ^4.8.0`
+- Added support for `topics` field.
+
+## 1.2.1
+
+- Added support for `funding` field.
+
+## 1.2.0
+
+- Added support for `screenshots` field.
+- Update `HostedDetails` to reflect how `hosted` dependencies are parsed in
+  Dart 2.15:
+   - Add `HostedDetails.declaredName` as the (optional) `name` property in a 
+     `hosted` block.
+   - `HostedDetails.name` now falls back to the name of the dependency if no
+      name is declared in the block.
+- Require Dart SDK >= 2.14.0
+
+## 1.1.0
+
+- Export `HostedDetails` publicly.
+
+## 1.0.0
+
+- Migrate to null-safety.
+- Pubspec: `author` and `authors` are both now deprecated.
+  See https://dart.dev/tools/pub/pubspec#authorauthors
+
+## 0.1.8
+
+- Allow the latest `package:pub_semver`.
+
+## 0.1.7
+
+- Allow `package:yaml` `v3.x`.
+
+## 0.1.6
+
+- Update SDK requirement to `>=2.7.0 <3.0.0`.
+- Allow `package:json_annotation` `v4.x`.
+
+## 0.1.5
+
+- Update SDK requirement to `>=2.2.0 <3.0.0`.
+- Support the latest `package:json_annotation`.
+
+## 0.1.4
+
+- Added `lenient` named argument to `Pubspec.fromJson` to ignore format and type errors.
+
+## 0.1.3
+
+- Added support for `flutter`, `issue_tracker`, `publish_to`, and `repository`
+  fields.
+
+## 0.1.2+3
+
+- Support the latest version of `package:json_annotation`.
+
+## 0.1.2+2
+
+- Support `package:json_annotation` v1.
+
+## 0.1.2+1
+
+- Support the Dart 2 stable release.
+
+## 0.1.2
+
+- Allow superfluous `version` keys with `git` and `path` dependencies.
+- Improve errors when unsupported keys are provided in dependencies.
+- Provide better errors with invalid `sdk` dependency values.
+- Support "scp-like syntax" for Git SSH URIs in the form
+  `[user@]host.xz:path/to/repo.git/`.
+
+## 0.1.1
+
+- Fixed name collision with error type in latest `package:json_annotation`.
+- Improved parsing of hosted dependencies and environment constraints.
+
+## 0.1.0
+
+- Initial release.
diff --git a/pkgs/pubspec_parse/LICENSE b/pkgs/pubspec_parse/LICENSE
new file mode 100644
index 0000000..4d1ad40
--- /dev/null
+++ b/pkgs/pubspec_parse/LICENSE
@@ -0,0 +1,27 @@
+Copyright 2018, the Dart project authors.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+    * Redistributions of source code must retain the above copyright
+      notice, this list of conditions and the following disclaimer.
+    * Redistributions in binary form must reproduce the above
+      copyright notice, this list of conditions and the following
+      disclaimer in the documentation and/or other materials provided
+      with the distribution.
+    * Neither the name of Google LLC nor the names of its
+      contributors may be used to endorse or promote products derived
+      from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/pkgs/pubspec_parse/README.md b/pkgs/pubspec_parse/README.md
new file mode 100644
index 0000000..916742a
--- /dev/null
+++ b/pkgs/pubspec_parse/README.md
@@ -0,0 +1,12 @@
+[![Dart CI](https://github.com/dart-lang/pubspec_parse/actions/workflows/test-package.yml/badge.svg)](https://github.com/dart-lang/pubspec_parse/actions/workflows/test-package.yml)
+[![pub package](https://img.shields.io/pub/v/pubspec_parse.svg)](https://pub.dev/packages/pubspec_parse)
+[![package publisher](https://img.shields.io/pub/publisher/pubspec_parse.svg)](https://pub.dev/packages/pubspec_parse/publisher)
+
+## What's this?
+
+Supports parsing `pubspec.yaml` files with robust error reporting and support
+for most of the documented features.
+
+## More information
+
+Read more about the [pubspec format](https://dart.dev/tools/pub/pubspec).
diff --git a/pkgs/pubspec_parse/analysis_options.yaml b/pkgs/pubspec_parse/analysis_options.yaml
new file mode 100644
index 0000000..93eeebf
--- /dev/null
+++ b/pkgs/pubspec_parse/analysis_options.yaml
@@ -0,0 +1,30 @@
+# https://dart.dev/guides/language/analysis-options
+include: package:dart_flutter_team_lints/analysis_options.yaml
+
+analyzer:
+  language:
+    strict-casts: true
+    strict-inference: true
+
+linter:
+  rules:
+    - avoid_bool_literals_in_conditional_expressions
+    - avoid_classes_with_only_static_members
+    - avoid_private_typedef_functions
+    - avoid_redundant_argument_values
+    - avoid_returning_this
+    - avoid_unused_constructor_parameters
+    - avoid_void_async
+    - cancel_subscriptions
+    - cascade_invocations
+    - join_return_with_assignment
+    - literal_only_boolean_expressions
+    - missing_whitespace_between_adjacent_strings
+    - no_adjacent_strings_in_list
+    - no_runtimeType_toString
+    - prefer_const_declarations
+    - prefer_expression_function_bodies
+    - prefer_final_locals
+    - require_trailing_commas
+    - unnecessary_await_in_return
+    - use_string_buffers
diff --git a/pkgs/pubspec_parse/build.yaml b/pkgs/pubspec_parse/build.yaml
new file mode 100644
index 0000000..2003bc2
--- /dev/null
+++ b/pkgs/pubspec_parse/build.yaml
@@ -0,0 +1,25 @@
+# Read about `build.yaml` at https://pub.dev/packages/build_config
+# To update generated code, run `pub run build_runner build`
+targets:
+  $default:
+    builders:
+      json_serializable:
+        generate_for:
+          - lib/src/pubspec.dart
+          - lib/src/dependency.dart
+        options:
+          any_map: true
+          checked: true
+          create_to_json: false
+          field_rename: snake
+
+      # The end-user of a builder which applies "source_gen|combining_builder"
+      # may configure the builder to ignore specific lints for their project
+      source_gen|combining_builder:
+        options:
+          ignore_for_file:
+          - deprecated_member_use_from_same_package
+          - lines_longer_than_80_chars
+          - require_trailing_commas
+          # https://github.com/google/json_serializable.dart/issues/945
+          - unnecessary_cast
diff --git a/pkgs/pubspec_parse/dart_test.yaml b/pkgs/pubspec_parse/dart_test.yaml
new file mode 100644
index 0000000..1d7ac69
--- /dev/null
+++ b/pkgs/pubspec_parse/dart_test.yaml
@@ -0,0 +1,3 @@
+tags:
+  presubmit-only:
+    skip: "Should only be run during presubmit"
diff --git a/pkgs/pubspec_parse/lib/pubspec_parse.dart b/pkgs/pubspec_parse/lib/pubspec_parse.dart
new file mode 100644
index 0000000..b5c12e4
--- /dev/null
+++ b/pkgs/pubspec_parse/lib/pubspec_parse.dart
@@ -0,0 +1,14 @@
+// Copyright (c) 2018, 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.
+
+export 'src/dependency.dart'
+    show
+        Dependency,
+        GitDependency,
+        HostedDependency,
+        HostedDetails,
+        PathDependency,
+        SdkDependency;
+export 'src/pubspec.dart' show Pubspec;
+export 'src/screenshot.dart' show Screenshot;
diff --git a/pkgs/pubspec_parse/lib/src/dependency.dart b/pkgs/pubspec_parse/lib/src/dependency.dart
new file mode 100644
index 0000000..24c65ea
--- /dev/null
+++ b/pkgs/pubspec_parse/lib/src/dependency.dart
@@ -0,0 +1,277 @@
+// Copyright (c) 2018, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'package:collection/collection.dart';
+import 'package:json_annotation/json_annotation.dart';
+import 'package:pub_semver/pub_semver.dart';
+import 'package:yaml/yaml.dart';
+
+part 'dependency.g.dart';
+
+Map<String, Dependency> parseDeps(Map? source) =>
+    source?.map((k, v) {
+      final key = k as String;
+      Dependency? value;
+      try {
+        value = _fromJson(v, k);
+      } on CheckedFromJsonException catch (e) {
+        if (e.map is! YamlMap) {
+          // This is likely a "synthetic" map created from a String value
+          // Use `source` to throw this exception with an actual YamlMap and
+          // extract the associated error information.
+          throw CheckedFromJsonException(source, key, e.className!, e.message);
+        }
+        rethrow;
+      }
+
+      if (value == null) {
+        throw CheckedFromJsonException(
+          source,
+          key,
+          'Pubspec',
+          'Not a valid dependency value.',
+        );
+      }
+      return MapEntry(key, value);
+    }) ??
+    {};
+
+const _sourceKeys = ['sdk', 'git', 'path', 'hosted'];
+
+/// Returns `null` if the data could not be parsed.
+Dependency? _fromJson(Object? data, String name) {
+  if (data is String || data == null) {
+    return _$HostedDependencyFromJson({'version': data});
+  }
+
+  if (data is Map) {
+    final matchedKeys =
+        data.keys.cast<String>().where((key) => key != 'version').toList();
+
+    if (data.isEmpty || (matchedKeys.isEmpty && data.containsKey('version'))) {
+      return _$HostedDependencyFromJson(data);
+    } else {
+      final firstUnrecognizedKey =
+          matchedKeys.firstWhereOrNull((k) => !_sourceKeys.contains(k));
+
+      return $checkedNew<Dependency>('Dependency', data, () {
+        if (firstUnrecognizedKey != null) {
+          throw UnrecognizedKeysException(
+            [firstUnrecognizedKey],
+            data,
+            _sourceKeys,
+          );
+        }
+        if (matchedKeys.length > 1) {
+          throw CheckedFromJsonException(
+            data,
+            matchedKeys[1],
+            'Dependency',
+            'A dependency may only have one source.',
+          );
+        }
+
+        final key = matchedKeys.single;
+
+        return switch (key) {
+          'git' => GitDependency.fromData(data[key]),
+          'path' => PathDependency.fromData(data[key]),
+          'sdk' => _$SdkDependencyFromJson(data),
+          'hosted' => _$HostedDependencyFromJson(data)
+            ..hosted?._nameOfPackage = name,
+          _ => throw StateError('There is a bug in pubspec_parse.'),
+        };
+      });
+    }
+  }
+
+  // Not a String or a Map – return null so parent logic can throw proper error
+  return null;
+}
+
+sealed class Dependency {}
+
+@JsonSerializable()
+class SdkDependency extends Dependency {
+  final String sdk;
+  @JsonKey(fromJson: _constraintFromString)
+  final VersionConstraint version;
+
+  SdkDependency(this.sdk, {VersionConstraint? version})
+      : version = version ?? VersionConstraint.any;
+
+  @override
+  bool operator ==(Object other) =>
+      other is SdkDependency && other.sdk == sdk && other.version == version;
+
+  @override
+  int get hashCode => Object.hash(sdk, version);
+
+  @override
+  String toString() => 'SdkDependency: $sdk';
+}
+
+@JsonSerializable()
+class GitDependency extends Dependency {
+  @JsonKey(fromJson: parseGitUri)
+  final Uri url;
+  final String? ref;
+  final String? path;
+
+  GitDependency(this.url, {this.ref, this.path});
+
+  factory GitDependency.fromData(Object? data) {
+    if (data is String) {
+      data = {'url': data};
+    }
+
+    if (data is Map) {
+      return _$GitDependencyFromJson(data);
+    }
+
+    throw ArgumentError.value(data, 'git', 'Must be a String or a Map.');
+  }
+
+  @override
+  bool operator ==(Object other) =>
+      other is GitDependency &&
+      other.url == url &&
+      other.ref == ref &&
+      other.path == path;
+
+  @override
+  int get hashCode => Object.hash(url, ref, path);
+
+  @override
+  String toString() => 'GitDependency: url@$url';
+}
+
+Uri? parseGitUriOrNull(String? value) =>
+    value == null ? null : parseGitUri(value);
+
+Uri parseGitUri(String value) => _tryParseScpUri(value) ?? Uri.parse(value);
+
+/// Supports URIs like `[user@]host.xz:path/to/repo.git/`
+/// See https://git-scm.com/docs/git-clone#_git_urls_a_id_urls_a
+Uri? _tryParseScpUri(String value) {
+  final colonIndex = value.indexOf(':');
+
+  if (colonIndex < 0) {
+    return null;
+  } else if (colonIndex == value.indexOf('://')) {
+    // If the first colon is part of a scheme, it's not an scp-like URI
+    return null;
+  }
+  final slashIndex = value.indexOf('/');
+
+  if (slashIndex >= 0 && slashIndex < colonIndex) {
+    // Per docs: This syntax is only recognized if there are no slashes before
+    // the first colon. This helps differentiate a local path that contains a
+    // colon. For example the local path foo:bar could be specified as an
+    // absolute path or ./foo:bar to avoid being misinterpreted as an ssh url.
+    return null;
+  }
+
+  final atIndex = value.indexOf('@');
+  if (colonIndex > atIndex) {
+    final user = atIndex >= 0 ? value.substring(0, atIndex) : null;
+    final host = value.substring(atIndex + 1, colonIndex);
+    final path = value.substring(colonIndex + 1);
+    return Uri(scheme: 'ssh', userInfo: user, host: host, path: path);
+  }
+  return null;
+}
+
+class PathDependency extends Dependency {
+  final String path;
+
+  PathDependency(this.path);
+
+  factory PathDependency.fromData(Object? data) {
+    if (data is String) {
+      return PathDependency(data);
+    }
+    throw ArgumentError.value(data, 'path', 'Must be a String.');
+  }
+
+  @override
+  bool operator ==(Object other) =>
+      other is PathDependency && other.path == path;
+
+  @override
+  int get hashCode => path.hashCode;
+
+  @override
+  String toString() => 'PathDependency: path@$path';
+}
+
+@JsonSerializable(disallowUnrecognizedKeys: true)
+class HostedDependency extends Dependency {
+  @JsonKey(fromJson: _constraintFromString)
+  final VersionConstraint version;
+
+  @JsonKey(disallowNullValue: true)
+  final HostedDetails? hosted;
+
+  HostedDependency({VersionConstraint? version, this.hosted})
+      : version = version ?? VersionConstraint.any;
+
+  @override
+  bool operator ==(Object other) =>
+      other is HostedDependency &&
+      other.version == version &&
+      other.hosted == hosted;
+
+  @override
+  int get hashCode => Object.hash(version, hosted);
+
+  @override
+  String toString() => 'HostedDependency: $version';
+}
+
+@JsonSerializable(disallowUnrecognizedKeys: true)
+class HostedDetails {
+  /// The name of the target dependency as declared in a `hosted` block.
+  ///
+  /// This may be null if no explicit name is present, for instance because the
+  /// hosted dependency was declared as a string (`hosted: pub.example.org`).
+  @JsonKey(name: 'name')
+  final String? declaredName;
+
+  @JsonKey(fromJson: parseGitUriOrNull, disallowNullValue: true)
+  final Uri? url;
+
+  @JsonKey(includeFromJson: false, includeToJson: false)
+  String? _nameOfPackage;
+
+  /// The name of this package on the package repository.
+  ///
+  /// If this hosted block has a [declaredName], that one will be used.
+  /// Otherwise, the name will be inferred from the surrounding package name.
+  String get name => declaredName ?? _nameOfPackage!;
+
+  HostedDetails(this.declaredName, this.url);
+
+  factory HostedDetails.fromJson(Object data) {
+    if (data is String) {
+      data = {'url': data};
+    }
+
+    if (data is Map) {
+      return _$HostedDetailsFromJson(data);
+    }
+
+    throw ArgumentError.value(data, 'hosted', 'Must be a Map or String.');
+  }
+
+  @override
+  bool operator ==(Object other) =>
+      other is HostedDetails && other.name == name && other.url == url;
+
+  @override
+  int get hashCode => Object.hash(name, url);
+}
+
+VersionConstraint _constraintFromString(String? input) =>
+    input == null ? VersionConstraint.any : VersionConstraint.parse(input);
diff --git a/pkgs/pubspec_parse/lib/src/dependency.g.dart b/pkgs/pubspec_parse/lib/src/dependency.g.dart
new file mode 100644
index 0000000..1a504f1
--- /dev/null
+++ b/pkgs/pubspec_parse/lib/src/dependency.g.dart
@@ -0,0 +1,72 @@
+// GENERATED CODE - DO NOT MODIFY BY HAND
+
+// ignore_for_file: deprecated_member_use_from_same_package, lines_longer_than_80_chars, require_trailing_commas, unnecessary_cast
+
+part of 'dependency.dart';
+
+// **************************************************************************
+// JsonSerializableGenerator
+// **************************************************************************
+
+SdkDependency _$SdkDependencyFromJson(Map json) => $checkedCreate(
+      'SdkDependency',
+      json,
+      ($checkedConvert) {
+        final val = SdkDependency(
+          $checkedConvert('sdk', (v) => v as String),
+          version: $checkedConvert(
+              'version', (v) => _constraintFromString(v as String?)),
+        );
+        return val;
+      },
+    );
+
+GitDependency _$GitDependencyFromJson(Map json) => $checkedCreate(
+      'GitDependency',
+      json,
+      ($checkedConvert) {
+        final val = GitDependency(
+          $checkedConvert('url', (v) => parseGitUri(v as String)),
+          ref: $checkedConvert('ref', (v) => v as String?),
+          path: $checkedConvert('path', (v) => v as String?),
+        );
+        return val;
+      },
+    );
+
+HostedDependency _$HostedDependencyFromJson(Map json) => $checkedCreate(
+      'HostedDependency',
+      json,
+      ($checkedConvert) {
+        $checkKeys(
+          json,
+          allowedKeys: const ['version', 'hosted'],
+          disallowNullValues: const ['hosted'],
+        );
+        final val = HostedDependency(
+          version: $checkedConvert(
+              'version', (v) => _constraintFromString(v as String?)),
+          hosted: $checkedConvert('hosted',
+              (v) => v == null ? null : HostedDetails.fromJson(v as Object)),
+        );
+        return val;
+      },
+    );
+
+HostedDetails _$HostedDetailsFromJson(Map json) => $checkedCreate(
+      'HostedDetails',
+      json,
+      ($checkedConvert) {
+        $checkKeys(
+          json,
+          allowedKeys: const ['name', 'url'],
+          disallowNullValues: const ['url'],
+        );
+        final val = HostedDetails(
+          $checkedConvert('name', (v) => v as String?),
+          $checkedConvert('url', (v) => parseGitUriOrNull(v as String?)),
+        );
+        return val;
+      },
+      fieldKeyMap: const {'declaredName': 'name'},
+    );
diff --git a/pkgs/pubspec_parse/lib/src/pubspec.dart b/pkgs/pubspec_parse/lib/src/pubspec.dart
new file mode 100644
index 0000000..1317a23
--- /dev/null
+++ b/pkgs/pubspec_parse/lib/src/pubspec.dart
@@ -0,0 +1,226 @@
+// Copyright (c) 2018, 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:checked_yaml/checked_yaml.dart';
+import 'package:json_annotation/json_annotation.dart';
+import 'package:pub_semver/pub_semver.dart';
+
+import 'dependency.dart';
+import 'screenshot.dart';
+
+part 'pubspec.g.dart';
+
+@JsonSerializable()
+class Pubspec {
+  // TODO: executables
+
+  final String name;
+
+  @JsonKey(fromJson: _versionFromString)
+  final Version? version;
+
+  final String? description;
+
+  /// This should be a URL pointing to the website for the package.
+  final String? homepage;
+
+  /// Specifies where to publish this package.
+  ///
+  /// Accepted values: `null`, `'none'` or an `http` or `https` URL.
+  ///
+  /// [More information](https://dart.dev/tools/pub/pubspec#publish_to).
+  final String? publishTo;
+
+  /// Optional field to specify the source code repository of the package.
+  /// Useful when a package has both a home page and a repository.
+  final Uri? repository;
+
+  /// Optional field to a web page where developers can report new issues or
+  /// view existing ones.
+  final Uri? issueTracker;
+
+  /// Optional field to list the URLs where the package authors accept
+  /// support or funding.
+  final List<Uri>? funding;
+
+  /// Optional field to list the topics that this packages belongs to.
+  final List<String>? topics;
+
+  /// Optional field to list advisories to be ignored by the client.
+  final List<String>? ignoredAdvisories;
+
+  /// Optional field for specifying included screenshot files.
+  @JsonKey(fromJson: parseScreenshots)
+  final List<Screenshot>? screenshots;
+
+  /// If there is exactly 1 value in [authors], returns it.
+  ///
+  /// If there are 0 or more than 1, returns `null`.
+  @Deprecated(
+    'See https://dart.dev/tools/pub/pubspec#authorauthors',
+  )
+  String? get author {
+    if (authors.length == 1) {
+      return authors.single;
+    }
+    return null;
+  }
+
+  @Deprecated(
+    'See https://dart.dev/tools/pub/pubspec#authorauthors',
+  )
+  final List<String> authors;
+  final String? documentation;
+
+  @JsonKey(fromJson: _environmentMap)
+  final Map<String, VersionConstraint?> environment;
+
+  @JsonKey(fromJson: parseDeps)
+  final Map<String, Dependency> dependencies;
+
+  @JsonKey(fromJson: parseDeps)
+  final Map<String, Dependency> devDependencies;
+
+  @JsonKey(fromJson: parseDeps)
+  final Map<String, Dependency> dependencyOverrides;
+
+  /// Optional configuration specific to [Flutter](https://flutter.io/)
+  /// packages.
+  ///
+  /// May include
+  /// [assets](https://flutter.io/docs/development/ui/assets-and-images)
+  /// and other settings.
+  final Map<String, dynamic>? flutter;
+
+  /// If [author] and [authors] are both provided, their values are combined
+  /// with duplicates eliminated.
+  Pubspec(
+    this.name, {
+    this.version,
+    this.publishTo,
+    @Deprecated(
+      'See https://dart.dev/tools/pub/pubspec#authorauthors',
+    )
+    String? author,
+    @Deprecated(
+      'See https://dart.dev/tools/pub/pubspec#authorauthors',
+    )
+    List<String>? authors,
+    Map<String, VersionConstraint?>? environment,
+    this.homepage,
+    this.repository,
+    this.issueTracker,
+    this.funding,
+    this.topics,
+    this.ignoredAdvisories,
+    this.screenshots,
+    this.documentation,
+    this.description,
+    Map<String, Dependency>? dependencies,
+    Map<String, Dependency>? devDependencies,
+    Map<String, Dependency>? dependencyOverrides,
+    this.flutter,
+  })  :
+        // ignore: deprecated_member_use_from_same_package
+        authors = _normalizeAuthors(author, authors),
+        environment = environment ?? const {},
+        dependencies = dependencies ?? const {},
+        devDependencies = devDependencies ?? const {},
+        dependencyOverrides = dependencyOverrides ?? const {} {
+    if (name.isEmpty) {
+      throw ArgumentError.value(name, 'name', '"name" cannot be empty.');
+    }
+
+    if (publishTo != null && publishTo != 'none') {
+      try {
+        final targetUri = Uri.parse(publishTo!);
+        if (!(targetUri.isScheme('http') || targetUri.isScheme('https'))) {
+          throw const FormatException('Must be an http or https URL.');
+        }
+      } on FormatException catch (e) {
+        throw ArgumentError.value(publishTo, 'publishTo', e.message);
+      }
+    }
+  }
+
+  factory Pubspec.fromJson(Map json, {bool lenient = false}) {
+    if (lenient) {
+      while (json.isNotEmpty) {
+        // Attempting to remove top-level properties that cause parsing errors.
+        try {
+          return _$PubspecFromJson(json);
+        } on CheckedFromJsonException catch (e) {
+          if (e.map == json && json.containsKey(e.key)) {
+            json = Map.from(json)..remove(e.key);
+            continue;
+          }
+          rethrow;
+        }
+      }
+    }
+
+    return _$PubspecFromJson(json);
+  }
+
+  /// Parses source [yaml] into [Pubspec].
+  ///
+  /// When [lenient] is set, top-level property-parsing or type cast errors are
+  /// ignored and `null` values are returned.
+  factory Pubspec.parse(String yaml, {Uri? sourceUrl, bool lenient = false}) =>
+      checkedYamlDecode(
+        yaml,
+        (map) => Pubspec.fromJson(map!, lenient: lenient),
+        sourceUrl: sourceUrl,
+      );
+
+  static List<String> _normalizeAuthors(String? author, List<String>? authors) {
+    final value = <String>{
+      if (author != null) author,
+      ...?authors,
+    };
+    return value.toList();
+  }
+}
+
+Version? _versionFromString(String? input) =>
+    input == null ? null : Version.parse(input);
+
+Map<String, VersionConstraint?> _environmentMap(Map? source) =>
+    source?.map((k, value) {
+      final key = k as String;
+      if (key == 'dart') {
+        // github.com/dart-lang/pub/blob/d84173eeb03c3/lib/src/pubspec.dart#L342
+        // 'dart' is not allowed as a key!
+        throw CheckedFromJsonException(
+          source,
+          'dart',
+          'VersionConstraint',
+          'Use "sdk" to for Dart SDK constraints.',
+          badKey: true,
+        );
+      }
+
+      VersionConstraint? constraint;
+      if (value == null) {
+        constraint = null;
+      } else if (value is String) {
+        try {
+          constraint = VersionConstraint.parse(value);
+        } on FormatException catch (e) {
+          throw CheckedFromJsonException(source, key, 'Pubspec', e.message);
+        }
+
+        return MapEntry(key, constraint);
+      } else {
+        throw CheckedFromJsonException(
+          source,
+          key,
+          'VersionConstraint',
+          '`$value` is not a String.',
+        );
+      }
+
+      return MapEntry(key, constraint);
+    }) ??
+    {};
diff --git a/pkgs/pubspec_parse/lib/src/pubspec.g.dart b/pkgs/pubspec_parse/lib/src/pubspec.g.dart
new file mode 100644
index 0000000..fc28571
--- /dev/null
+++ b/pkgs/pubspec_parse/lib/src/pubspec.g.dart
@@ -0,0 +1,64 @@
+// GENERATED CODE - DO NOT MODIFY BY HAND
+
+// ignore_for_file: deprecated_member_use_from_same_package, lines_longer_than_80_chars, require_trailing_commas, unnecessary_cast
+
+part of 'pubspec.dart';
+
+// **************************************************************************
+// JsonSerializableGenerator
+// **************************************************************************
+
+Pubspec _$PubspecFromJson(Map json) => $checkedCreate(
+      'Pubspec',
+      json,
+      ($checkedConvert) {
+        final val = Pubspec(
+          $checkedConvert('name', (v) => v as String),
+          version: $checkedConvert(
+              'version', (v) => _versionFromString(v as String?)),
+          publishTo: $checkedConvert('publish_to', (v) => v as String?),
+          author: $checkedConvert('author', (v) => v as String?),
+          authors: $checkedConvert('authors',
+              (v) => (v as List<dynamic>?)?.map((e) => e as String).toList()),
+          environment:
+              $checkedConvert('environment', (v) => _environmentMap(v as Map?)),
+          homepage: $checkedConvert('homepage', (v) => v as String?),
+          repository: $checkedConvert(
+              'repository', (v) => v == null ? null : Uri.parse(v as String)),
+          issueTracker: $checkedConvert('issue_tracker',
+              (v) => v == null ? null : Uri.parse(v as String)),
+          funding: $checkedConvert(
+              'funding',
+              (v) => (v as List<dynamic>?)
+                  ?.map((e) => Uri.parse(e as String))
+                  .toList()),
+          topics: $checkedConvert('topics',
+              (v) => (v as List<dynamic>?)?.map((e) => e as String).toList()),
+          ignoredAdvisories: $checkedConvert('ignored_advisories',
+              (v) => (v as List<dynamic>?)?.map((e) => e as String).toList()),
+          screenshots: $checkedConvert(
+              'screenshots', (v) => parseScreenshots(v as List?)),
+          documentation: $checkedConvert('documentation', (v) => v as String?),
+          description: $checkedConvert('description', (v) => v as String?),
+          dependencies:
+              $checkedConvert('dependencies', (v) => parseDeps(v as Map?)),
+          devDependencies:
+              $checkedConvert('dev_dependencies', (v) => parseDeps(v as Map?)),
+          dependencyOverrides: $checkedConvert(
+              'dependency_overrides', (v) => parseDeps(v as Map?)),
+          flutter: $checkedConvert(
+              'flutter',
+              (v) => (v as Map?)?.map(
+                    (k, e) => MapEntry(k as String, e),
+                  )),
+        );
+        return val;
+      },
+      fieldKeyMap: const {
+        'publishTo': 'publish_to',
+        'issueTracker': 'issue_tracker',
+        'ignoredAdvisories': 'ignored_advisories',
+        'devDependencies': 'dev_dependencies',
+        'dependencyOverrides': 'dependency_overrides'
+      },
+    );
diff --git a/pkgs/pubspec_parse/lib/src/screenshot.dart b/pkgs/pubspec_parse/lib/src/screenshot.dart
new file mode 100644
index 0000000..f5f0be2
--- /dev/null
+++ b/pkgs/pubspec_parse/lib/src/screenshot.dart
@@ -0,0 +1,65 @@
+// Copyright (c) 2021, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'package:json_annotation/json_annotation.dart';
+
+@JsonSerializable()
+class Screenshot {
+  final String description;
+  final String path;
+
+  Screenshot(this.description, this.path);
+}
+
+List<Screenshot> parseScreenshots(List? input) {
+  final res = <Screenshot>[];
+  if (input == null) {
+    return res;
+  }
+
+  for (final e in input) {
+    if (e is! Map) continue;
+
+    final description = e['description'];
+    if (description == null) {
+      throw CheckedFromJsonException(
+        e,
+        'description',
+        'Screenshot',
+        'Missing required key `description`',
+      );
+    }
+
+    if (description is! String) {
+      throw CheckedFromJsonException(
+        e,
+        'description',
+        'Screenshot',
+        '`$description` is not a String',
+      );
+    }
+
+    final path = e['path'];
+    if (path == null) {
+      throw CheckedFromJsonException(
+        e,
+        'path',
+        'Screenshot',
+        'Missing required key `path`',
+      );
+    }
+
+    if (path is! String) {
+      throw CheckedFromJsonException(
+        e,
+        'path',
+        'Screenshot',
+        '`$path` is not a String',
+      );
+    }
+
+    res.add(Screenshot(description, path));
+  }
+  return res;
+}
diff --git a/pkgs/pubspec_parse/pubspec.yaml b/pkgs/pubspec_parse/pubspec.yaml
new file mode 100644
index 0000000..bcdaa31
--- /dev/null
+++ b/pkgs/pubspec_parse/pubspec.yaml
@@ -0,0 +1,31 @@
+name: pubspec_parse
+version: 1.4.0-wip
+description: >-
+  Simple package for parsing pubspec.yaml files with a type-safe API and rich
+  error reporting.
+repository: https://github.com/dart-lang/pubspec_parse
+topics:
+- dart-pub
+
+environment:
+  sdk: ^3.2.0
+
+dependencies:
+  checked_yaml: ^2.0.1
+  collection: ^1.15.0
+  json_annotation: ^4.8.0
+  pub_semver: ^2.0.0
+  yaml: ^3.0.0
+
+dev_dependencies:
+  build_runner: ^2.2.1
+  build_verify: ^3.0.0
+  dart_flutter_team_lints: ^3.0.0
+  json_serializable: ^6.6.0
+  path: ^1.8.0
+  # Needed because we are configuring `combining_builder`
+  source_gen: ^1.2.3
+  stack_trace: ^1.10.0
+  test: ^1.21.6
+  test_descriptor: ^2.0.0
+  test_process: ^2.0.0
diff --git a/pkgs/pubspec_parse/test/dependency_test.dart b/pkgs/pubspec_parse/test/dependency_test.dart
new file mode 100644
index 0000000..f1e4f57
--- /dev/null
+++ b/pkgs/pubspec_parse/test/dependency_test.dart
@@ -0,0 +1,446 @@
+// Copyright (c) 2018, 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:io';
+
+import 'package:pub_semver/pub_semver.dart';
+import 'package:pubspec_parse/pubspec_parse.dart';
+import 'package:test/test.dart';
+
+import 'test_utils.dart';
+
+void main() {
+  group('hosted', _hostedDependency);
+  group('git', _gitDependency);
+  group('sdk', _sdkDependency);
+  group('path', _pathDependency);
+
+  group('errors', () {
+    test('List', () {
+      _expectThrows(
+        [],
+        r'''
+line 4, column 10: Unsupported value for "dep". Not a valid dependency value.
+  ╷
+4 │   "dep": []
+  │          ^^
+  ╵''',
+      );
+    });
+
+    test('int', () {
+      _expectThrows(
+        42,
+        r'''
+line 4, column 10: Unsupported value for "dep". Not a valid dependency value.
+  ╷
+4 │     "dep": 42
+  │ ┌──────────^
+5 │ │  }
+  │ └─^
+  ╵''',
+      );
+    });
+
+    test('map with too many keys', () {
+      _expectThrows(
+        {'path': 'a', 'git': 'b'},
+        r'''
+line 6, column 11: Unsupported value for "git". A dependency may only have one source.
+  ╷
+6 │    "git": "b"
+  │           ^^^
+  ╵''',
+      );
+    });
+
+    test('map with unsupported keys', () {
+      _expectThrows(
+        {'bob': 'a', 'jones': 'b'},
+        r'''
+line 5, column 4: Unrecognized keys: [bob]; supported keys: [sdk, git, path, hosted]
+  ╷
+5 │    "bob": "a",
+  │    ^^^^^
+  ╵''',
+      );
+    });
+  });
+}
+
+void _hostedDependency() {
+  test('null', () async {
+    final dep = await _dependency<HostedDependency>(null);
+    expect(dep.version.toString(), 'any');
+    expect(dep.hosted, isNull);
+    expect(dep.toString(), 'HostedDependency: any');
+  });
+
+  test('empty map', () async {
+    final dep = await _dependency<HostedDependency>({});
+    expect(dep.hosted, isNull);
+    expect(dep.toString(), 'HostedDependency: any');
+  });
+
+  test('string version', () async {
+    final dep = await _dependency<HostedDependency>('^1.0.0');
+    expect(dep.version.toString(), '^1.0.0');
+    expect(dep.hosted, isNull);
+    expect(dep.toString(), 'HostedDependency: ^1.0.0');
+  });
+
+  test('bad string version', () {
+    _expectThrows(
+      'not a version',
+      r'''
+line 4, column 10: Unsupported value for "dep". Could not parse version "not a version". Unknown text at "not a version".
+  ╷
+4 │   "dep": "not a version"
+  │          ^^^^^^^^^^^^^^^
+  ╵''',
+    );
+  });
+
+  test('map w/ just version', () async {
+    final dep = await _dependency<HostedDependency>({'version': '^1.0.0'});
+    expect(dep.version.toString(), '^1.0.0');
+    expect(dep.hosted, isNull);
+    expect(dep.toString(), 'HostedDependency: ^1.0.0');
+  });
+
+  test('map w/ version and hosted as Map', () async {
+    final dep = await _dependency<HostedDependency>({
+      'version': '^1.0.0',
+      'hosted': {'name': 'hosted_name', 'url': 'https://hosted_url'},
+    });
+    expect(dep.version.toString(), '^1.0.0');
+    expect(dep.hosted!.name, 'hosted_name');
+    expect(dep.hosted!.url.toString(), 'https://hosted_url');
+    expect(dep.toString(), 'HostedDependency: ^1.0.0');
+  });
+
+  test('map /w hosted as a map without name', () async {
+    final dep = await _dependency<HostedDependency>(
+      {
+        'version': '^1.0.0',
+        'hosted': {'url': 'https://hosted_url'},
+      },
+      skipTryPub: true, // todo: Unskip once pub supports this syntax
+    );
+    expect(dep.version.toString(), '^1.0.0');
+    expect(dep.hosted!.declaredName, isNull);
+    expect(dep.hosted!.name, 'dep');
+    expect(dep.hosted!.url.toString(), 'https://hosted_url');
+    expect(dep.toString(), 'HostedDependency: ^1.0.0');
+  });
+
+  test('map w/ bad version value', () {
+    _expectThrows(
+      {
+        'version': 'not a version',
+        'hosted': {'name': 'hosted_name', 'url': 'hosted_url'},
+      },
+      r'''
+line 5, column 15: Unsupported value for "version". Could not parse version "not a version". Unknown text at "not a version".
+  ╷
+5 │    "version": "not a version",
+  │               ^^^^^^^^^^^^^^^
+  ╵''',
+    );
+  });
+
+  test('map w/ extra keys should fail', () {
+    _expectThrows(
+      {
+        'version': '^1.0.0',
+        'hosted': {'name': 'hosted_name', 'url': 'hosted_url'},
+        'not_supported': null,
+      },
+      r'''
+line 10, column 4: Unrecognized keys: [not_supported]; supported keys: [sdk, git, path, hosted]
+   ╷
+10 │    "not_supported": null
+   │    ^^^^^^^^^^^^^^^
+   ╵''',
+    );
+  });
+
+  test('map w/ version and hosted as String', () async {
+    final dep = await _dependency<HostedDependency>(
+      {'version': '^1.0.0', 'hosted': 'hosted_url'},
+      skipTryPub: true, // todo: Unskip once put supports this
+    );
+    expect(dep.version.toString(), '^1.0.0');
+    expect(dep.hosted!.declaredName, isNull);
+    expect(dep.hosted!.name, 'dep');
+    expect(dep.hosted!.url, Uri.parse('hosted_url'));
+    expect(dep.toString(), 'HostedDependency: ^1.0.0');
+  });
+
+  test('map w/ hosted as String', () async {
+    final dep = await _dependency<HostedDependency>({'hosted': 'hosted_url'});
+    expect(dep.version, VersionConstraint.any);
+    expect(dep.hosted!.declaredName, isNull);
+    expect(dep.hosted!.name, 'dep');
+    expect(dep.hosted!.url, Uri.parse('hosted_url'));
+    expect(dep.toString(), 'HostedDependency: any');
+  });
+
+  test('map w/ null hosted should error', () {
+    _expectThrows(
+      {'hosted': null},
+      r'''
+line 5, column 4: These keys had `null` values, which is not allowed: [hosted]
+  ╷
+5 │    "hosted": null
+  │    ^^^^^^^^
+  ╵''',
+    );
+  });
+
+  test('map w/ null version is fine', () async {
+    final dep = await _dependency<HostedDependency>({'version': null});
+    expect(dep.version, VersionConstraint.any);
+    expect(dep.hosted, isNull);
+    expect(dep.toString(), 'HostedDependency: any');
+  });
+}
+
+void _sdkDependency() {
+  test('without version', () async {
+    final dep = await _dependency<SdkDependency>({'sdk': 'flutter'});
+    expect(dep.sdk, 'flutter');
+    expect(dep.version, VersionConstraint.any);
+    expect(dep.toString(), 'SdkDependency: flutter');
+  });
+
+  test('with version', () async {
+    final dep = await _dependency<SdkDependency>(
+      {'sdk': 'flutter', 'version': '>=1.2.3 <2.0.0'},
+    );
+    expect(dep.sdk, 'flutter');
+    expect(dep.version.toString(), '>=1.2.3 <2.0.0');
+    expect(dep.toString(), 'SdkDependency: flutter');
+  });
+
+  test('null content', () {
+    _expectThrowsContaining(
+      {'sdk': null},
+      r"type 'Null' is not a subtype of type 'String'",
+    );
+  });
+
+  test('number content', () {
+    _expectThrowsContaining(
+      {'sdk': 42},
+      r"type 'int' is not a subtype of type 'String'",
+    );
+  });
+}
+
+void _gitDependency() {
+  test('string', () async {
+    final dep = await _dependency<GitDependency>({'git': 'url'});
+    expect(dep.url.toString(), 'url');
+    expect(dep.path, isNull);
+    expect(dep.ref, isNull);
+    expect(dep.toString(), 'GitDependency: url@url');
+  });
+
+  test('string with version key is ignored', () async {
+    // Regression test for https://github.com/dart-lang/pubspec_parse/issues/13
+    final dep =
+        await _dependency<GitDependency>({'git': 'url', 'version': '^1.2.3'});
+    expect(dep.url.toString(), 'url');
+    expect(dep.path, isNull);
+    expect(dep.ref, isNull);
+    expect(dep.toString(), 'GitDependency: url@url');
+  });
+
+  test('string with user@ URL', () async {
+    final skipTryParse = Platform.environment.containsKey('TRAVIS');
+    if (skipTryParse) {
+      print('FYI: not validating git@ URI on travis due to failure');
+    }
+    final dep = await _dependency<GitDependency>(
+      {'git': 'git@localhost:dep.git'},
+      skipTryPub: skipTryParse,
+    );
+    expect(dep.url.toString(), 'ssh://git@localhost/dep.git');
+    expect(dep.path, isNull);
+    expect(dep.ref, isNull);
+    expect(dep.toString(), 'GitDependency: url@ssh://git@localhost/dep.git');
+  });
+
+  test('string with random extra key fails', () {
+    _expectThrows(
+      {'git': 'url', 'bob': '^1.2.3'},
+      r'''
+line 6, column 4: Unrecognized keys: [bob]; supported keys: [sdk, git, path, hosted]
+  ╷
+6 │    "bob": "^1.2.3"
+  │    ^^^^^
+  ╵''',
+    );
+  });
+
+  test('map', () async {
+    final dep = await _dependency<GitDependency>({
+      'git': {'url': 'url', 'path': 'path', 'ref': 'ref'},
+    });
+    expect(dep.url.toString(), 'url');
+    expect(dep.path, 'path');
+    expect(dep.ref, 'ref');
+    expect(dep.toString(), 'GitDependency: url@url');
+  });
+
+  test('git - null content', () {
+    _expectThrows(
+      {'git': null},
+      r'''
+line 5, column 11: Unsupported value for "git". Must be a String or a Map.
+  ╷
+5 │      "git": null
+  │ ┌───────────^
+6 │ │   }
+  │ └──^
+  ╵''',
+    );
+  });
+
+  test('git - int content', () {
+    _expectThrows(
+      {'git': 42},
+      r'''
+line 5, column 11: Unsupported value for "git". Must be a String or a Map.
+  ╷
+5 │      "git": 42
+  │ ┌───────────^
+6 │ │   }
+  │ └──^
+  ╵''',
+    );
+  });
+
+  test('git - empty map', () {
+    _expectThrowsContaining(
+      {'git': <String, dynamic>{}},
+      r"type 'Null' is not a subtype of type 'String'",
+    );
+  });
+
+  test('git - null url', () {
+    _expectThrowsContaining(
+      {
+        'git': {'url': null},
+      },
+      r"type 'Null' is not a subtype of type 'String'",
+    );
+  });
+
+  test('git - int url', () {
+    _expectThrowsContaining(
+      {
+        'git': {'url': 42},
+      },
+      r"type 'int' is not a subtype of type 'String'",
+    );
+  });
+}
+
+void _pathDependency() {
+  test('valid', () async {
+    final dep = await _dependency<PathDependency>({'path': '../path'});
+    expect(dep.path, '../path');
+    expect(dep.toString(), 'PathDependency: path@../path');
+  });
+
+  test('valid with version key is ignored', () async {
+    final dep = await _dependency<PathDependency>(
+      {'path': '../path', 'version': '^1.2.3'},
+    );
+    expect(dep.path, '../path');
+    expect(dep.toString(), 'PathDependency: path@../path');
+  });
+
+  test('valid with random extra key fails', () {
+    _expectThrows(
+      {'path': '../path', 'bob': '^1.2.3'},
+      r'''
+line 6, column 4: Unrecognized keys: [bob]; supported keys: [sdk, git, path, hosted]
+  ╷
+6 │    "bob": "^1.2.3"
+  │    ^^^^^
+  ╵''',
+    );
+  });
+
+  test('null content', () {
+    _expectThrows(
+      {'path': null},
+      r'''
+line 5, column 12: Unsupported value for "path". Must be a String.
+  ╷
+5 │      "path": null
+  │ ┌────────────^
+6 │ │   }
+  │ └──^
+  ╵''',
+    );
+  });
+
+  test('int content', () {
+    _expectThrows(
+      {'path': 42},
+      r'''
+line 5, column 12: Unsupported value for "path". Must be a String.
+  ╷
+5 │      "path": 42
+  │ ┌────────────^
+6 │ │   }
+  │ └──^
+  ╵''',
+    );
+  });
+}
+
+void _expectThrows(Object content, String expectedError) {
+  expectParseThrows(
+    {
+      'name': 'sample',
+      'dependencies': {'dep': content},
+    },
+    expectedError,
+  );
+}
+
+void _expectThrowsContaining(Object content, String errorText) {
+  expectParseThrowsContaining(
+    {
+      'name': 'sample',
+      'dependencies': {'dep': content},
+    },
+    errorText,
+  );
+}
+
+Future<T> _dependency<T extends Dependency>(
+  Object? content, {
+  bool skipTryPub = false,
+}) async {
+  final value = await parse(
+    {
+      ...defaultPubspec,
+      'dependencies': {'dep': content},
+    },
+    skipTryPub: skipTryPub,
+  );
+  expect(value.name, 'sample');
+  expect(value.dependencies, hasLength(1));
+
+  final entry = value.dependencies.entries.single;
+  expect(entry.key, 'dep');
+
+  return entry.value as T;
+}
diff --git a/pkgs/pubspec_parse/test/ensure_build_test.dart b/pkgs/pubspec_parse/test/ensure_build_test.dart
new file mode 100644
index 0000000..689f6fe
--- /dev/null
+++ b/pkgs/pubspec_parse/test/ensure_build_test.dart
@@ -0,0 +1,15 @@
+// Copyright (c) 2018, 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.
+
+@Timeout.factor(2)
+@TestOn('vm')
+@Tags(['presubmit-only'])
+library;
+
+import 'package:build_verify/build_verify.dart';
+import 'package:test/test.dart';
+
+void main() {
+  test('ensure_build', expectBuildClean);
+}
diff --git a/pkgs/pubspec_parse/test/git_uri_test.dart b/pkgs/pubspec_parse/test/git_uri_test.dart
new file mode 100644
index 0000000..be89ba8
--- /dev/null
+++ b/pkgs/pubspec_parse/test/git_uri_test.dart
@@ -0,0 +1,25 @@
+import 'package:pubspec_parse/src/dependency.dart';
+import 'package:test/test.dart';
+
+void main() {
+  for (var item in {
+    'git@github.com:google/grinder.dart.git':
+        'ssh://git@github.com/google/grinder.dart.git',
+    'host.xz:path/to/repo.git/': 'ssh://host.xz/path/to/repo.git/',
+    'http:path/to/repo.git/': 'ssh://http/path/to/repo.git/',
+    'file:path/to/repo.git/': 'ssh://file/path/to/repo.git/',
+    './foo:bar': 'foo%3Abar',
+    '/path/to/repo.git/': '/path/to/repo.git/',
+    'file:///path/to/repo.git/': 'file:///path/to/repo.git/',
+  }.entries) {
+    test(item.key, () {
+      final uri = parseGitUri(item.key);
+
+      printOnFailure(
+        [uri.scheme, uri.userInfo, uri.host, uri.port, uri.path].join('\n'),
+      );
+
+      expect(uri, Uri.parse(item.value));
+    });
+  }
+}
diff --git a/pkgs/pubspec_parse/test/parse_test.dart b/pkgs/pubspec_parse/test/parse_test.dart
new file mode 100644
index 0000000..6251f41
--- /dev/null
+++ b/pkgs/pubspec_parse/test/parse_test.dart
@@ -0,0 +1,715 @@
+// Copyright (c) 2018, 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.
+
+// ignore_for_file: deprecated_member_use_from_same_package
+// ignore_for_file: lines_longer_than_80_chars
+
+import 'package:pub_semver/pub_semver.dart';
+import 'package:test/test.dart';
+
+import 'test_utils.dart';
+
+void main() {
+  test('minimal set values', () async {
+    final value = await parse(defaultPubspec);
+    expect(value.name, 'sample');
+    expect(value.version, isNull);
+    expect(value.publishTo, isNull);
+    expect(value.description, isNull);
+    expect(value.homepage, isNull);
+    expect(value.author, isNull);
+    expect(value.authors, isEmpty);
+    expect(
+      value.environment,
+      {'sdk': VersionConstraint.parse('>=2.12.0 <3.0.0')},
+    );
+    expect(value.documentation, isNull);
+    expect(value.dependencies, isEmpty);
+    expect(value.devDependencies, isEmpty);
+    expect(value.dependencyOverrides, isEmpty);
+    expect(value.flutter, isNull);
+    expect(value.repository, isNull);
+    expect(value.issueTracker, isNull);
+    expect(value.screenshots, isEmpty);
+  });
+
+  test('all fields set', () async {
+    final version = Version.parse('1.2.3');
+    final sdkConstraint = VersionConstraint.parse('>=2.12.0 <3.0.0');
+    final value = await parse({
+      'name': 'sample',
+      'version': version.toString(),
+      'publish_to': 'none',
+      'author': 'name@example.com',
+      'environment': {'sdk': sdkConstraint.toString()},
+      'description': 'description',
+      'homepage': 'homepage',
+      'documentation': 'documentation',
+      'repository': 'https://github.com/example/repo',
+      'issue_tracker': 'https://github.com/example/repo/issues',
+      'funding': [
+        'https://patreon.com/example',
+      ],
+      'topics': ['widget', 'button'],
+      'ignored_advisories': ['111', '222'],
+      'screenshots': [
+        {'description': 'my screenshot', 'path': 'path/to/screenshot'},
+      ],
+    });
+    expect(value.name, 'sample');
+    expect(value.version, version);
+    expect(value.publishTo, 'none');
+    expect(value.description, 'description');
+    expect(value.homepage, 'homepage');
+    expect(value.author, 'name@example.com');
+    expect(value.authors, ['name@example.com']);
+    expect(value.environment, hasLength(1));
+    expect(value.environment, containsPair('sdk', sdkConstraint));
+    expect(value.documentation, 'documentation');
+    expect(value.dependencies, isEmpty);
+    expect(value.devDependencies, isEmpty);
+    expect(value.dependencyOverrides, isEmpty);
+    expect(value.repository, Uri.parse('https://github.com/example/repo'));
+    expect(
+      value.issueTracker,
+      Uri.parse('https://github.com/example/repo/issues'),
+    );
+    expect(value.funding, hasLength(1));
+    expect(value.funding!.single.toString(), 'https://patreon.com/example');
+    expect(value.topics, hasLength(2));
+    expect(value.topics!.first, 'widget');
+    expect(value.topics!.last, 'button');
+    expect(value.ignoredAdvisories, hasLength(2));
+    expect(value.ignoredAdvisories!.first, '111');
+    expect(value.ignoredAdvisories!.last, '222');
+    expect(value.screenshots, hasLength(1));
+    expect(value.screenshots!.first.description, 'my screenshot');
+    expect(value.screenshots!.first.path, 'path/to/screenshot');
+  });
+
+  test('environment values can be null', () async {
+    final value = await parse(
+      {
+        'name': 'sample',
+        'environment': {
+          'sdk': '>=2.12.0 <3.0.0',
+          'bob': null,
+        },
+      },
+      skipTryPub: true,
+    );
+    expect(value.name, 'sample');
+    expect(value.environment, hasLength(2));
+    expect(value.environment, containsPair('bob', isNull));
+  });
+
+  group('publish_to', () {
+    for (var entry in {
+      42: "Unsupported value for \"publish_to\". type 'int' is not a subtype of type 'String?'",
+      '##not a uri!': r'''
+line 3, column 16: Unsupported value for "publish_to". Must be an http or https URL.
+  ╷
+3 │  "publish_to": "##not a uri!"
+  │                ^^^^^^^^^^^^^^
+  ╵''',
+      '/cool/beans': r'''
+line 3, column 16: Unsupported value for "publish_to". Must be an http or https URL.
+  ╷
+3 │  "publish_to": "/cool/beans"
+  │                ^^^^^^^^^^^^^
+  ╵''',
+      'file:///Users/kevmoo/': r'''
+line 3, column 16: Unsupported value for "publish_to". Must be an http or https URL.
+  ╷
+3 │  "publish_to": "file:///Users/kevmoo/"
+  │                ^^^^^^^^^^^^^^^^^^^^^^^
+  ╵''',
+    }.entries) {
+      test('cannot be `${entry.key}`', () {
+        expectParseThrowsContaining(
+          {'name': 'sample', 'publish_to': entry.key},
+          entry.value,
+          skipTryPub: true,
+        );
+      });
+    }
+
+    for (var entry in {
+      null: null,
+      'http': 'http://example.com',
+      'https': 'https://example.com',
+      'none': 'none',
+    }.entries) {
+      test('can be ${entry.key}', () async {
+        final value = await parse({
+          ...defaultPubspec,
+          'publish_to': entry.value,
+        });
+        expect(value.publishTo, entry.value);
+      });
+    }
+  });
+
+  group('author, authors', () {
+    test('one author', () async {
+      final value = await parse({
+        ...defaultPubspec,
+        'author': 'name@example.com',
+      });
+      expect(value.author, 'name@example.com');
+      expect(value.authors, ['name@example.com']);
+    });
+
+    test('one author, via authors', () async {
+      final value = await parse({
+        ...defaultPubspec,
+        'authors': ['name@example.com'],
+      });
+      expect(value.author, 'name@example.com');
+      expect(value.authors, ['name@example.com']);
+    });
+
+    test('many authors', () async {
+      final value = await parse({
+        ...defaultPubspec,
+        'authors': ['name@example.com', 'name2@example.com'],
+      });
+      expect(value.author, isNull);
+      expect(value.authors, ['name@example.com', 'name2@example.com']);
+    });
+
+    test('author and authors', () async {
+      final value = await parse({
+        ...defaultPubspec,
+        'author': 'name@example.com',
+        'authors': ['name2@example.com'],
+      });
+      expect(value.author, isNull);
+      expect(value.authors, ['name@example.com', 'name2@example.com']);
+    });
+
+    test('duplicate author values', () async {
+      final value = await parse({
+        ...defaultPubspec,
+        'author': 'name@example.com',
+        'authors': ['name@example.com', 'name@example.com'],
+      });
+      expect(value.author, 'name@example.com');
+      expect(value.authors, ['name@example.com']);
+    });
+
+    test('flutter', () async {
+      final value = await parse({
+        ...defaultPubspec,
+        'flutter': {'key': 'value'},
+      });
+      expect(value.flutter, {'key': 'value'});
+    });
+  });
+
+  group('invalid', () {
+    test('null', () {
+      expectParseThrows(
+        null,
+        r'''
+line 1, column 1: Not a map
+  ╷
+1 │ null
+  │ ^^^^
+  ╵''',
+      );
+    });
+    test('empty string', () {
+      expectParseThrows(
+        '',
+        r'''
+line 1, column 1: Not a map
+  ╷
+1 │ ""
+  │ ^^
+  ╵''',
+      );
+    });
+    test('array', () {
+      expectParseThrows(
+        [],
+        r'''
+line 1, column 1: Not a map
+  ╷
+1 │ []
+  │ ^^
+  ╵''',
+      );
+    });
+
+    test('missing name', () {
+      expectParseThrowsContaining(
+        {},
+        "Missing key \"name\". type 'Null' is not a subtype of type 'String'",
+      );
+    });
+
+    test('null name value', () {
+      expectParseThrowsContaining(
+        {'name': null},
+        "Unsupported value for \"name\". type 'Null' is not a subtype of type 'String'",
+      );
+    });
+
+    test('empty name value', () {
+      expectParseThrows(
+        {'name': ''},
+        r'''
+line 2, column 10: Unsupported value for "name". "name" cannot be empty.
+  ╷
+2 │  "name": ""
+  │          ^^
+  ╵''',
+      );
+    });
+
+    test('"dart" is an invalid environment key', () {
+      expectParseThrows(
+        {
+          'name': 'sample',
+          'environment': {'dart': 'cool'},
+        },
+        r'''
+line 4, column 3: Use "sdk" to for Dart SDK constraints.
+  ╷
+4 │   "dart": "cool"
+  │   ^^^^^^
+  ╵''',
+      );
+    });
+
+    test('environment values cannot be int', () {
+      expectParseThrows(
+        {
+          'name': 'sample',
+          'environment': {'sdk': 42},
+        },
+        r'''
+line 4, column 10: Unsupported value for "sdk". `42` is not a String.
+  ╷
+4 │     "sdk": 42
+  │ ┌──────────^
+5 │ │  }
+  │ └─^
+  ╵''',
+      );
+    });
+
+    test('version', () {
+      expectParseThrows(
+        {'name': 'sample', 'version': 'invalid'},
+        r'''
+line 3, column 13: Unsupported value for "version". Could not parse "invalid".
+  ╷
+3 │  "version": "invalid"
+  │             ^^^^^^^^^
+  ╵''',
+      );
+    });
+
+    test('invalid environment value', () {
+      expectParseThrows(
+        {
+          'name': 'sample',
+          'environment': {'sdk': 'silly'},
+        },
+        r'''
+line 4, column 10: Unsupported value for "sdk". Could not parse version "silly". Unknown text at "silly".
+  ╷
+4 │   "sdk": "silly"
+  │          ^^^^^^^
+  ╵''',
+      );
+    });
+
+    test('bad repository url', () {
+      expectParseThrowsContaining(
+        {
+          ...defaultPubspec,
+          'repository': {'x': 'y'},
+        },
+        "Unsupported value for \"repository\". type 'YamlMap' is not a subtype of type 'String'",
+        skipTryPub: true,
+      );
+    });
+
+    test('bad issue_tracker url', () {
+      expectParseThrowsContaining(
+        {
+          'name': 'sample',
+          'issue_tracker': {'x': 'y'},
+        },
+        "Unsupported value for \"issue_tracker\". type 'YamlMap' is not a subtype of type 'String'",
+        skipTryPub: true,
+      );
+    });
+  });
+
+  group('funding', () {
+    test('not a list', () {
+      expectParseThrowsContaining(
+        {
+          ...defaultPubspec,
+          'funding': 1,
+        },
+        "Unsupported value for \"funding\". type 'int' is not a subtype of type 'List<dynamic>?'",
+        skipTryPub: true,
+      );
+    });
+
+    test('not an uri', () {
+      expectParseThrowsContaining(
+        {
+          ...defaultPubspec,
+          'funding': [1],
+        },
+        "Unsupported value for \"funding\". type 'int' is not a subtype of type 'String'",
+        skipTryPub: true,
+      );
+    });
+
+    test('not an uri', () {
+      expectParseThrows(
+        {
+          ...defaultPubspec,
+          'funding': ['ht tps://example.com/'],
+        },
+        r'''
+line 6, column 13: Unsupported value for "funding". Illegal scheme character at offset 2.
+  ╷
+6 │    "funding": [
+  │ ┌─────────────^
+7 │ │   "ht tps://example.com/"
+8 │ └  ]
+  ╵''',
+        skipTryPub: true,
+      );
+    });
+  });
+  group('topics', () {
+    test('not a list', () {
+      expectParseThrowsContaining(
+        {
+          ...defaultPubspec,
+          'topics': 1,
+        },
+        "Unsupported value for \"topics\". type 'int' is not a subtype of type 'List<dynamic>?'",
+        skipTryPub: true,
+      );
+    });
+
+    test('not a string', () {
+      expectParseThrowsContaining(
+        {
+          ...defaultPubspec,
+          'topics': [1],
+        },
+        "Unsupported value for \"topics\". type 'int' is not a subtype of type 'String'",
+        skipTryPub: true,
+      );
+    });
+
+    test('invalid data - lenient', () async {
+      final value = await parse(
+        {
+          ...defaultPubspec,
+          'topics': [1],
+        },
+        skipTryPub: true,
+        lenient: true,
+      );
+      expect(value.name, 'sample');
+      expect(value.topics, isNull);
+    });
+  });
+
+  group('ignored_advisories', () {
+    test('not a list', () {
+      expectParseThrowsContaining(
+        {
+          ...defaultPubspec,
+          'ignored_advisories': 1,
+        },
+        "Unsupported value for \"ignored_advisories\". type 'int' is not a subtype of type 'List<dynamic>?'",
+        skipTryPub: true,
+      );
+    });
+
+    test('not a string', () {
+      expectParseThrowsContaining(
+        {
+          ...defaultPubspec,
+          'ignored_advisories': [1],
+        },
+        "Unsupported value for \"ignored_advisories\". type 'int' is not a subtype of type 'String'",
+        skipTryPub: true,
+      );
+    });
+
+    test('invalid data - lenient', () async {
+      final value = await parse(
+        {
+          ...defaultPubspec,
+          'ignored_advisories': [1],
+        },
+        skipTryPub: true,
+        lenient: true,
+      );
+      expect(value.name, 'sample');
+      expect(value.ignoredAdvisories, isNull);
+    });
+  });
+
+  group('screenshots', () {
+    test('one screenshot', () async {
+      final value = await parse({
+        ...defaultPubspec,
+        'screenshots': [
+          {'description': 'my screenshot', 'path': 'path/to/screenshot'},
+        ],
+      });
+      expect(value.screenshots, hasLength(1));
+      expect(value.screenshots!.first.description, 'my screenshot');
+      expect(value.screenshots!.first.path, 'path/to/screenshot');
+    });
+
+    test('many screenshots', () async {
+      final value = await parse({
+        ...defaultPubspec,
+        'screenshots': [
+          {'description': 'my screenshot', 'path': 'path/to/screenshot'},
+          {
+            'description': 'my second screenshot',
+            'path': 'path/to/screenshot2',
+          },
+        ],
+      });
+      expect(value.screenshots, hasLength(2));
+      expect(value.screenshots!.first.description, 'my screenshot');
+      expect(value.screenshots!.first.path, 'path/to/screenshot');
+      expect(value.screenshots!.last.description, 'my second screenshot');
+      expect(value.screenshots!.last.path, 'path/to/screenshot2');
+    });
+
+    test('one screenshot plus invalid entries', () async {
+      final value = await parse({
+        ...defaultPubspec,
+        'screenshots': [
+          42,
+          {
+            'description': 'my screenshot',
+            'path': 'path/to/screenshot',
+            'extraKey': 'not important',
+          },
+          'not a screenshot',
+        ],
+      });
+      expect(value.screenshots, hasLength(1));
+      expect(value.screenshots!.first.description, 'my screenshot');
+      expect(value.screenshots!.first.path, 'path/to/screenshot');
+    });
+
+    test('invalid entries', () async {
+      final value = await parse({
+        ...defaultPubspec,
+        'screenshots': [
+          42,
+          'not a screenshot',
+        ],
+      });
+      expect(value.screenshots, isEmpty);
+    });
+
+    test('missing key `dessription', () {
+      expectParseThrows(
+        {
+          ...defaultPubspec,
+          'screenshots': [
+            {'path': 'my/path'},
+          ],
+        },
+        r'''
+line 7, column 3: Missing key "description". Missing required key `description`
+  ╷
+7 │ ┌   {
+8 │ │    "path": "my/path"
+9 │ └   }
+  ╵''',
+        skipTryPub: true,
+      );
+    });
+
+    test('missing key `path`', () {
+      expectParseThrows(
+        {
+          ...defaultPubspec,
+          'screenshots': [
+            {'description': 'my screenshot'},
+          ],
+        },
+        r'''
+line 7, column 3: Missing key "path". Missing required key `path`
+  ╷
+7 │ ┌   {
+8 │ │    "description": "my screenshot"
+9 │ └   }
+  ╵''',
+        skipTryPub: true,
+      );
+    });
+
+    test('Value of description not a String`', () {
+      expectParseThrows(
+        {
+          ...defaultPubspec,
+          'screenshots': [
+            {'description': 42},
+          ],
+        },
+        r'''
+line 8, column 19: Unsupported value for "description". `42` is not a String
+  ╷
+8 │      "description": 42
+  │ ┌───────────────────^
+9 │ │   }
+  │ └──^
+  ╵''',
+        skipTryPub: true,
+      );
+    });
+
+    test('Value of path not a String`', () {
+      expectParseThrows(
+        {
+          ...defaultPubspec,
+          'screenshots': [
+            {
+              'description': '',
+              'path': 42,
+            },
+          ],
+        },
+        r'''
+line 9, column 12: Unsupported value for "path". `42` is not a String
+   ╷
+9  │      "path": 42
+   │ ┌────────────^
+10 │ │   }
+   │ └──^
+   ╵''',
+        skipTryPub: true,
+      );
+    });
+
+    test('invalid screenshot - lenient', () async {
+      final value = await parse(
+        {
+          ...defaultPubspec,
+          'screenshots': 'Invalid value',
+        },
+        lenient: true,
+      );
+      expect(value.name, 'sample');
+      expect(value.screenshots, isEmpty);
+    });
+  });
+
+  group('lenient', () {
+    test('null', () {
+      expectParseThrows(
+        null,
+        r'''
+line 1, column 1: Not a map
+  ╷
+1 │ null
+  │ ^^^^
+  ╵''',
+        lenient: true,
+      );
+    });
+
+    test('empty string', () {
+      expectParseThrows(
+        '',
+        r'''
+line 1, column 1: Not a map
+  ╷
+1 │ ""
+  │ ^^
+  ╵''',
+        lenient: true,
+      );
+    });
+
+    test('name cannot be empty', () {
+      expectParseThrowsContaining(
+        {},
+        "Missing key \"name\". type 'Null' is not a subtype of type 'String'",
+        lenient: true,
+      );
+    });
+
+    test('bad repository url', () async {
+      final value = await parse(
+        {
+          ...defaultPubspec,
+          'repository': {'x': 'y'},
+        },
+        lenient: true,
+      );
+      expect(value.name, 'sample');
+      expect(value.repository, isNull);
+    });
+
+    test('bad issue_tracker url', () async {
+      final value = await parse(
+        {
+          ...defaultPubspec,
+          'issue_tracker': {'x': 'y'},
+        },
+        lenient: true,
+      );
+      expect(value.name, 'sample');
+      expect(value.issueTracker, isNull);
+    });
+
+    test('multiple bad values', () async {
+      final value = await parse(
+        {
+          ...defaultPubspec,
+          'repository': {'x': 'y'},
+          'issue_tracker': {'x': 'y'},
+        },
+        lenient: true,
+      );
+      expect(value.name, 'sample');
+      expect(value.repository, isNull);
+      expect(value.issueTracker, isNull);
+    });
+
+    test('deep error throws with lenient', () {
+      expect(
+        () => parse(
+          {
+            'name': 'sample',
+            'dependencies': {
+              'foo': {
+                'git': {'url': 1},
+              },
+            },
+            'issue_tracker': {'x': 'y'},
+          },
+          skipTryPub: true,
+          lenient: true,
+        ),
+        throwsException,
+      );
+    });
+  });
+}
diff --git a/pkgs/pubspec_parse/test/pub_utils.dart b/pkgs/pubspec_parse/test/pub_utils.dart
new file mode 100644
index 0000000..a60aa2a
--- /dev/null
+++ b/pkgs/pubspec_parse/test/pub_utils.dart
@@ -0,0 +1,88 @@
+// Copyright (c) 2018, 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 'dart:io';
+
+import 'package:path/path.dart' as p;
+import 'package:test/test.dart';
+import 'package:test_descriptor/test_descriptor.dart' as d;
+import 'package:test_process/test_process.dart';
+
+Future<ProcResult> tryPub(String content) async {
+  await d.file('pubspec.yaml', content).create();
+
+  final proc = await TestProcess.start(
+    Platform.resolvedExecutable,
+    ['pub', 'get', '--offline'],
+    workingDirectory: d.sandbox,
+    // Don't pass current process VM options to child
+    environment: Map.from(Platform.environment)..remove('DART_VM_OPTIONS'),
+  );
+
+  final result = await ProcResult.fromTestProcess(proc);
+
+  printOnFailure(
+    [
+      '-----BEGIN pub output-----',
+      result.toString().trim(),
+      '-----END pub output-----',
+    ].join('\n'),
+  );
+
+  if (result.exitCode == 0) {
+    final lockContent =
+        File(p.join(d.sandbox, 'pubspec.lock')).readAsStringSync();
+
+    printOnFailure(
+      [
+        '-----BEGIN pubspec.lock-----',
+        lockContent.trim(),
+        '-----END pubspec.lock-----',
+      ].join('\n'),
+    );
+  }
+
+  return result;
+}
+
+class ProcResult {
+  final int exitCode;
+  final List<ProcLine> lines;
+
+  bool get cleanParse => exitCode == 0 || exitCode == 66 || exitCode == 69;
+
+  ProcResult(this.exitCode, this.lines);
+
+  static Future<ProcResult> fromTestProcess(TestProcess proc) async {
+    final items = <ProcLine>[];
+
+    final values = await Future.wait([
+      proc.exitCode,
+      proc.stdoutStream().forEach((line) => items.add(ProcLine(false, line))),
+      proc.stderrStream().forEach((line) => items.add(ProcLine(true, line))),
+    ]);
+
+    return ProcResult(values[0] as int, items);
+  }
+
+  @override
+  String toString() {
+    final buffer = StringBuffer('Exit code: $exitCode');
+    for (var line in lines) {
+      buffer.write('\n$line');
+    }
+    return buffer.toString();
+  }
+}
+
+class ProcLine {
+  final bool isError;
+  final String line;
+
+  ProcLine(this.isError, this.line);
+
+  @override
+  String toString() => '${isError ? 'err' : 'out'}  $line';
+}
diff --git a/pkgs/pubspec_parse/test/test_utils.dart b/pkgs/pubspec_parse/test/test_utils.dart
new file mode 100644
index 0000000..cc46522
--- /dev/null
+++ b/pkgs/pubspec_parse/test/test_utils.dart
@@ -0,0 +1,157 @@
+// Copyright (c) 2018, 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:convert';
+
+import 'package:checked_yaml/checked_yaml.dart';
+import 'package:json_annotation/json_annotation.dart';
+import 'package:pubspec_parse/pubspec_parse.dart';
+import 'package:stack_trace/stack_trace.dart';
+import 'package:test/test.dart';
+
+import 'pub_utils.dart';
+
+const defaultPubspec = {
+  'name': 'sample',
+  'environment': {'sdk': '>=2.12.0 <3.0.0'},
+};
+
+String _encodeJson(Object? input) =>
+    const JsonEncoder.withIndent(' ').convert(input);
+
+Matcher _throwsParsedYamlException(String prettyValue) => throwsA(
+      const TypeMatcher<ParsedYamlException>().having(
+        (e) {
+          final message = e.formattedMessage;
+          printOnFailure("Actual error format:\nr'''\n$message'''");
+          _printDebugParsedYamlException(e);
+          return message;
+        },
+        'formattedMessage',
+        prettyValue,
+      ),
+    );
+
+void _printDebugParsedYamlException(ParsedYamlException e) {
+  var innerError = e.innerError;
+  StackTrace? innerStack;
+
+  if (innerError is CheckedFromJsonException) {
+    final cfje = innerError;
+
+    if (cfje.innerError != null) {
+      innerError = cfje.innerError;
+      innerStack = cfje.innerStack;
+    }
+  }
+
+  if (innerError != null) {
+    final items = [innerError];
+    if (innerStack != null) {
+      items.add(Trace.format(innerStack));
+    }
+
+    final content =
+        LineSplitter.split(items.join('\n')).map((e) => '  $e').join('\n');
+
+    printOnFailure('Inner error details:\n$content');
+  }
+}
+
+Future<Pubspec> parse(
+  Object? content, {
+  bool quietOnError = false,
+  bool skipTryPub = false,
+  bool lenient = false,
+}) async {
+  final encoded = _encodeJson(content);
+
+  ProcResult? pubResult;
+  if (!skipTryPub) {
+    // ignore: deprecated_member_use
+    pubResult = await tryPub(encoded);
+    expect(pubResult, isNotNull);
+  }
+
+  try {
+    final value = Pubspec.parse(encoded, lenient: lenient);
+
+    if (pubResult != null) {
+      addTearDown(() {
+        expect(
+          pubResult!.cleanParse,
+          isTrue,
+          reason:
+              'On success, parsing from the pub client should also succeed.',
+        );
+      });
+    }
+    return value;
+  } catch (e) {
+    if (pubResult != null) {
+      addTearDown(() {
+        expect(
+          pubResult!.cleanParse,
+          isFalse,
+          reason: 'On failure, parsing from the pub client should also fail.',
+        );
+      });
+    }
+    if (e is ParsedYamlException) {
+      if (!quietOnError) {
+        _printDebugParsedYamlException(e);
+      }
+    }
+    rethrow;
+  }
+}
+
+void expectParseThrows(
+  Object? content,
+  String expectedError, {
+  bool skipTryPub = false,
+  bool lenient = false,
+}) =>
+    expect(
+      () => parse(
+        content,
+        lenient: lenient,
+        quietOnError: true,
+        skipTryPub: skipTryPub,
+      ),
+      _throwsParsedYamlException(expectedError),
+    );
+
+void expectParseThrowsContaining(
+  Object? content,
+  String errorFragment, {
+  bool skipTryPub = false,
+  bool lenient = false,
+}) {
+  expect(
+    () => parse(
+      content,
+      lenient: lenient,
+      quietOnError: true,
+      skipTryPub: skipTryPub,
+    ),
+    _throwsParsedYamlExceptionContaining(errorFragment),
+  );
+}
+
+// ignore: prefer_expression_function_bodies
+Matcher _throwsParsedYamlExceptionContaining(String errorFragment) {
+  return throwsA(
+    const TypeMatcher<ParsedYamlException>().having(
+      (e) {
+        final message = e.formattedMessage;
+        printOnFailure("Actual error format:\nr'''\n$message'''");
+        _printDebugParsedYamlException(e);
+        return message;
+      },
+      'formattedMessage',
+      contains(errorFragment),
+    ),
+  );
+}