feat(api_summary): Move api_summary package into the tools monorepo (#2412)
diff --git a/.github/workflows/api_summary.yaml b/.github/workflows/api_summary.yaml
new file mode 100644
index 0000000..1213569
--- /dev/null
+++ b/.github/workflows/api_summary.yaml
@@ -0,0 +1,44 @@
+name: package:api_summary
+permissions: read-all
+
+on:
+  pull_request:
+    paths:
+      - '.github/workflows/api_summary.yaml'
+      - 'pkgs/api_summary/**'
+  push:
+    branches: [ main ]
+    paths:
+      - '.github/workflows/api_summary.yaml'
+      - 'pkgs/api_summary/**'
+  schedule:
+    - cron: '0 0 * * 0' # weekly
+
+defaults:
+  run:
+    working-directory: pkgs/api_summary
+
+jobs:
+  build:
+    runs-on: ubuntu-latest
+    strategy:
+      fail-fast: false
+      matrix:
+        sdk: ['3.12', dev]
+        include:
+          - sdk: dev
+            check-formatting: true
+    steps:
+      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
+      - uses: dart-lang/setup-dart@65eb853c7ba17dde3be364c3d2858773e7144260
+        with:
+          sdk: ${{matrix.sdk}}
+
+      - run: dart pub get
+
+      - run: dart analyze --fatal-infos
+
+      - run: dart format --output=none --set-exit-if-changed .
+        if: ${{matrix.check-formatting}}
+
+      - run: dart test
diff --git a/README.md b/README.md
index 90c4818..f372290 100644
--- a/README.md
+++ b/README.md
@@ -14,6 +14,7 @@
 
 | Package | Description | Issues | Version |
 | --- | --- | --- | --- |
+| [api_summary](pkgs/api_summary/) | Creates an API summary for a package. | [![issues](https://img.shields.io/badge/issues-4774bc)][api_summary_issues] | [![pub package](https://img.shields.io/pub/v/api_summary.svg)](https://pub.dev/packages/api_summary) |
 | [bazel_worker](pkgs/bazel_worker/) | Protocol and utilities to implement or invoke persistent bazel workers. | [![issues](https://img.shields.io/badge/issues-4774bc)][bazel_worker_issues] | [![pub package](https://img.shields.io/pub/v/bazel_worker.svg)](https://pub.dev/packages/bazel_worker) |
 | [benchmark_harness](pkgs/benchmark_harness/) | The official Dart project benchmark harness. | [![issues](https://img.shields.io/badge/issues-4774bc)][benchmark_harness_issues] | [![pub package](https://img.shields.io/pub/v/benchmark_harness.svg)](https://pub.dev/packages/benchmark_harness) |
 | [boolean_selector](pkgs/boolean_selector/) | A flexible syntax for boolean expressions, based on a simplified version of Dart's expression syntax. | [![issues](https://img.shields.io/badge/issues-4774bc)][boolean_selector_issues] | [![pub package](https://img.shields.io/pub/v/boolean_selector.svg)](https://pub.dev/packages/boolean_selector) |
@@ -56,6 +57,7 @@
 | [yaml](pkgs/yaml/) | A parser for YAML, a human-friendly data serialization standard | [![issues](https://img.shields.io/badge/issues-4774bc)][yaml_issues] | [![pub package](https://img.shields.io/pub/v/yaml.svg)](https://pub.dev/packages/yaml) |
 | [yaml_edit](pkgs/yaml_edit/) | A library for YAML manipulation with comment and whitespace preservation. | [![issues](https://img.shields.io/badge/issues-4774bc)][yaml_edit_issues] | [![pub package](https://img.shields.io/pub/v/yaml_edit.svg)](https://pub.dev/packages/yaml_edit) |
 
+[api_summary_issues]: https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Aapi_summary
 [bazel_worker_issues]: https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Abazel_worker
 [benchmark_harness_issues]: https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Abenchmark_harness
 [boolean_selector_issues]: https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Aboolean_selector
diff --git a/pkgs/api_summary/CHANGELOG.md b/pkgs/api_summary/CHANGELOG.md
new file mode 100644
index 0000000..d4f1a2a
--- /dev/null
+++ b/pkgs/api_summary/CHANGELOG.md
@@ -0,0 +1,3 @@
+## 0.1.0-wip
+
+- First release.
diff --git a/pkgs/api_summary/LICENSE b/pkgs/api_summary/LICENSE
new file mode 100644
index 0000000..9035a41
--- /dev/null
+++ b/pkgs/api_summary/LICENSE
@@ -0,0 +1,27 @@
+Copyright 2026, 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/api_summary/README.md b/pkgs/api_summary/README.md
new file mode 100644
index 0000000..2f9a7a5
--- /dev/null
+++ b/pkgs/api_summary/README.md
@@ -0,0 +1,161 @@
+[![Build Status](https://github.com/dart-lang/tools/actions/workflows/api_summary.yaml/badge.svg)](https://github.com/dart-lang/tools/actions/workflows/api_summary.yaml)
+[![pub package](https://img.shields.io/pub/v/api_summary.svg)](https://pub.dev/packages/api_summary)
+[![package publisher](https://img.shields.io/pub/publisher/api_summary.svg)](https://pub.dev/packages/api_summary/publisher)
+
+A library and command-line tool to create a human-readable text summary of the
+public API of a Dart package. This is highly suitable for tracking the public
+API footprints and ensuring that API modifications are visible during code
+reviews (e.g. using `diff` tests).
+
+> [!NOTE]
+> For robust breaking change tracking, you should use
+> [dart_apitool](https://pub.dev/packages/dart_apitool).
+
+## Command Line Usage
+
+You can use the command-line tool to generate an API summary for any package
+containing a `pubspec.yaml` file.
+
+### Installing Globally
+
+Activate the package using `dart pub global`:
+
+```bash
+dart pub global activate api_summary
+```
+
+Then run the tool:
+
+```bash
+api_summary --package-path /path/to/your/package
+```
+
+Or, to run against the package in the current working directory, just run:
+
+```bash
+api_summary
+```
+
+### Running via `dart run`
+
+Alternatively, you can run the executable from within a package directory if
+`api_summary` is a dependency:
+
+```bash
+dart run api_summary
+```
+
+### Options
+
+* `-p, --package-path`: The path to the package directory to summarize
+  (defaults to the current working directory).
+* `-h, --help`: Prints usage instructions.
+
+## Programmatic Usage
+
+You can also use this package programmatically inside your Dart projects, such
+as in automated testing or continuous integration scripts.
+
+Add `api_summary` to your `pubspec.yaml`:
+
+```yaml
+dependencies:
+  api_summary: ^0.1.0-wip
+```
+
+### Basic Example
+
+Call the `summarizePackage` function to generate a package's public API
+representation:
+
+```dart
+import 'dart:io';
+import 'package:api_summary/api_summary.dart';
+
+void main() async {
+  final summary = await summarizePackage('/path/to/package', 'my_package');
+  print(summary);
+}
+```
+
+An executable programmatic example is also available in the
+[example/example.dart](example/example.dart) file.
+
+### Customizing the Summary
+
+Extend the `ApiSummaryCustomizer` class to customize what is displayed in the
+API summary. For example, to exclude specific public classes or only display
+details of certain elements:
+
+```dart
+import 'package:api_summary/api_summary.dart';
+import 'package:analyzer/dart/element/element.dart';
+
+base class MyCustomizer extends ApiSummaryCustomizer {
+  @override
+  bool shouldShowDetails(Element element) {
+    // Exclude elements named 'InternalHelper' from details printout
+    if (element.name == 'InternalHelper') {
+      return false;
+    }
+    return super.shouldShowDetails(element);
+  }
+}
+
+void main() async {
+  final summary = await summarizePackage(
+    '/path/to/package',
+    'my_package',
+    createCustomizer: () => MyCustomizer(),
+  );
+  print(summary);
+}
+```
+
+## Golden File / Diff Testing
+
+A common best practice with `api_summary` is to verify in a unit test that the
+generated summary matches a checked-in golden file (e.g. `api.txt`). If a
+developer introduces an accidental breaking change or adds a new public API
+element, the test will fail on the `diff`, prompting them to audit and
+intentionally update the golden file.
+
+Below is an example of such a test (available in the
+[test/app_test.dart](test/app_test.dart) file):
+
+```dart
+import 'dart:io';
+import 'package:path/path.dart' as p;
+import 'package:test/test.dart';
+import 'package:api_summary/api_summary.dart';
+
+void main() {
+  test('public API has not changed unexpectedly', () async {
+    final packageDir = Directory.current.path;
+    final goldenFile = File(p.join(packageDir, 'api.txt'));
+
+    final actualOutput = await summarizePackage(packageDir, 'my_package');
+
+    if (!goldenFile.existsSync()) {
+      // In a new setup or after updates, generate the golden file first
+      goldenFile.writeAsStringSync(actualOutput);
+      fail('Golden file api.txt did not exist and has been generated. Please review and commit it.');
+    }
+
+    final expectedOutput = goldenFile.readAsStringSync();
+    expect(actualOutput, equals(expectedOutput));
+  });
+}
+```
+
+## Status: experimental
+
+**NOTE**: This package is currently experimental and published under the
+[tools.dart.dev](https://dart.dev/dart-team-packages) pub publisher in order to
+solicit feedback.
+
+These packages have a much higher expected rate of API and breaking changes.
+
+Your feedback is valuable and will help us evolve this package. For general
+feedback, suggestions, and comments, please file an issue in the
+[bug tracker](https://github.com/dart-lang/tools/issues).
diff --git a/pkgs/api_summary/analysis_options.yaml b/pkgs/api_summary/analysis_options.yaml
new file mode 100644
index 0000000..ae9be57
--- /dev/null
+++ b/pkgs/api_summary/analysis_options.yaml
@@ -0,0 +1,22 @@
+include: package:dart_flutter_team_lints/analysis_options.yaml
+
+analyzer:
+  language:
+    strict-raw-types: true
+
+linter:
+  rules:
+    - avoid_catches_without_on_clauses
+    - avoid_unused_constructor_parameters
+    - cancel_subscriptions
+    - 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_in_for_each
+    - prefer_final_locals
+    - simple_directive_paths
+    - unnecessary_await_in_return
+    - unnecessary_ignore
diff --git a/pkgs/api_summary/api.txt b/pkgs/api_summary/api.txt
new file mode 100644
index 0000000..7f6a593
--- /dev/null
+++ b/pkgs/api_summary/api.txt
@@ -0,0 +1,25 @@
+package:api_summary/api_summary.dart:
+  summarizePackage (function: Future<String> Function(String, String, {ApiSummaryCustomizer Function()? createCustomizer}))
+  ApiSummaryCustomizer (class extends Object, base):
+    new (constructor: ApiSummaryCustomizer Function())
+    analysisContext= (setter: AnalysisContext)
+    packageName= (setter: String)
+    publicApiLibraries= (setter: Iterable<LibraryElement>)
+    topLevelPublicElements (getter: Set<Element>)
+    topLevelPublicElements= (setter: Set<Element>)
+    initialScanComplete (method: Future<void> Function())
+    setupComplete (method: Future<void> Function())
+    shouldShowDetails (method: bool Function(Element))
+dart:async:
+  Future (referenced)
+dart:core:
+  Iterable (referenced)
+  Object (referenced)
+  Set (referenced)
+  String (referenced)
+  bool (referenced)
+package:analyzer/dart/analysis/analysis_context.dart:
+  AnalysisContext (referenced)
+package:analyzer/dart/element/element.dart:
+  Element (referenced)
+  LibraryElement (referenced)
diff --git a/pkgs/api_summary/bin/api_summary.dart b/pkgs/api_summary/bin/api_summary.dart
new file mode 100644
index 0000000..54002b0
--- /dev/null
+++ b/pkgs/api_summary/bin/api_summary.dart
@@ -0,0 +1,81 @@
+// Copyright (c) 2026, 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:api_summary/api_summary.dart';
+import 'package:args/args.dart';
+import 'package:path/path.dart' as p;
+import 'package:yaml/yaml.dart';
+
+Future<void> main(List<String> arguments) async {
+  try {
+    final results = parser.parse(arguments);
+
+    if (results.flag('help')) {
+      print('Usage: api_summary [options]');
+      print(parser.usage);
+      return;
+    }
+
+    final packagePath =
+        results.option('package-path') ?? Directory.current.path;
+    final absolutePath = p.normalize(p.absolute(packagePath));
+    final pubspecFile = File(p.join(absolutePath, 'pubspec.yaml'));
+
+    if (!pubspecFile.existsSync()) {
+      stderr.writeln('Error: No pubspec.yaml found at "$absolutePath".');
+      exitCode = 1;
+      return;
+    }
+
+    final packageName = _extractPackageName(pubspecFile);
+    final summary = await summarizePackage(absolutePath, packageName);
+    stdout.write(summary);
+  } on FormatException catch (e) {
+    stderr.writeln('Error: ${e.message}');
+    stderr.writeln('\nUsage: api_summary [options]');
+    stderr.writeln(parser.usage);
+    exitCode = 64;
+    return;
+  }
+}
+
+final parser = ArgParser()
+  ..addOption(
+    'package-path',
+    abbr: 'p',
+    help:
+        'The path to the package to summarize. Defaults to the current '
+        'directory.',
+  )
+  ..addFlag(
+    'help',
+    abbr: 'h',
+    help: 'Print this usage information.',
+    negatable: false,
+  );
+
+String _extractPackageName(File pubspecFile) {
+  final content = pubspecFile.readAsStringSync();
+  final yaml = loadYaml(content);
+  if (yaml is! Map) {
+    throw ArgumentError(
+      'Expected pubspec.yaml at ${pubspecFile.path} to be a YAML map.',
+    );
+  }
+  final name = yaml['name'];
+  if (name == null) {
+    throw ArgumentError(
+      'Could not find a "name" field in pubspec.yaml at ${pubspecFile.path}.',
+    );
+  }
+  if (name is! String) {
+    throw ArgumentError(
+      'The "name" field in pubspec.yaml at ${pubspecFile.path} must be a '
+      'String.',
+    );
+  }
+  return name;
+}
diff --git a/pkgs/api_summary/example/example.dart b/pkgs/api_summary/example/example.dart
new file mode 100644
index 0000000..e84ec90
--- /dev/null
+++ b/pkgs/api_summary/example/example.dart
@@ -0,0 +1,20 @@
+// Copyright (c) 2026, 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:api_summary/api_summary.dart';
+import 'package:path/path.dart' as p;
+
+void main() async {
+  // Locate this package's root directory (current working directory)
+  final packagePath = p.normalize(p.absolute(Directory.current.path));
+
+  print('Generating API summary for the api_summary package...\n');
+
+  // Call summarizePackage to get the public API footprints
+  final summary = await summarizePackage(packagePath, 'api_summary');
+
+  // Output the generated summary to stdout
+  print(summary);
+}
diff --git a/pkgs/api_summary/lib/api_summary.dart b/pkgs/api_summary/lib/api_summary.dart
new file mode 100644
index 0000000..f38e53f
--- /dev/null
+++ b/pkgs/api_summary/lib/api_summary.dart
@@ -0,0 +1,52 @@
+// Copyright (c) 2026, 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/analysis/analysis_context_collection.dart';
+import 'package:analyzer/file_system/physical_file_system.dart';
+import 'src/api_description.dart';
+import 'src/api_summary_customizer.dart';
+import 'src/node.dart';
+
+export 'src/api_summary_customizer.dart' show ApiSummaryCustomizer;
+
+/// Creates a human-readable text summary of the public API of a package, in a
+/// format suitable for auditing with a `diff` tool.
+///
+/// [packagePath] is the path to the directory containing the package's
+/// `pubspec.yaml` file.
+///
+/// [packageName] is the name of the package.
+///
+/// If [createCustomizer] is provided, it will be called to create an instance
+/// of [ApiSummaryCustomizer] which will be used to customize the behavior of
+/// the tool.
+Future<String> summarizePackage(
+  String packagePath,
+  String packageName, {
+  ApiSummaryCustomizer Function()? createCustomizer,
+}) async {
+  final provider = PhysicalResourceProvider.INSTANCE;
+  final libPath = provider.pathContext.join(packagePath, 'lib');
+  final collection = AnalysisContextCollection(
+    resourceProvider: provider,
+    includedPaths: [libPath],
+  );
+  if (collection.contexts.isEmpty) {
+    throw ArgumentError('No analysis context found for "$packagePath".');
+  }
+  if (collection.contexts.length > 1) {
+    throw ArgumentError(
+      'Multiple analysis contexts found for "$packagePath". '
+      'Only a single package is supported.',
+    );
+  }
+  final context = collection.contexts.single;
+  final publicApi = ApiDescription(
+    packageName,
+    createCustomizer?.call() ?? ApiSummaryCustomizer(),
+  );
+  final stringBuffer = StringBuffer();
+  printNodes(stringBuffer, await publicApi.build(context));
+  return stringBuffer.toString();
+}
diff --git a/pkgs/api_summary/lib/src/api_description.dart b/pkgs/api_summary/lib/src/api_description.dart
new file mode 100644
index 0000000..7f88e35
--- /dev/null
+++ b/pkgs/api_summary/lib/src/api_description.dart
@@ -0,0 +1,492 @@
+// Copyright (c) 2026, 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:collection';
+
+import 'package:analyzer/dart/analysis/analysis_context.dart';
+import 'package:analyzer/dart/analysis/results.dart';
+import 'package:analyzer/dart/element/element.dart';
+import 'package:analyzer/dart/element/nullability_suffix.dart';
+import 'package:analyzer/dart/element/type.dart';
+import 'package:collection/collection.dart';
+
+import 'api_summary_customizer.dart';
+import 'extensions.dart';
+import 'member_sorting.dart';
+import 'node.dart';
+import 'unique_namer.dart';
+import 'uri_sorting.dart';
+
+/// Data structure keeping track of a package's API while walking it to produce
+/// `api.txt`.
+class ApiDescription {
+  final ApiSummaryCustomizer _customizer;
+
+  final String _pkgName;
+
+  /// Top level elements that have already had their child elements dumped.
+  ///
+  /// If an element is seen again in a different library, it will be followed
+  /// with `(see above)` (rather than having its child elements dumped twice).
+  final _dumpedTopLevelElements = <Element>{};
+
+  /// Top level elements that have been referenced so far and haven't yet been
+  /// processed by [build].
+  ///
+  /// This is used to ensure that all elements referred to by the public API
+  /// (e.g., by being mentioned in the type of an API element) also show up in
+  /// the output.
+  final _potentiallyDanglingReferences = Queue<Element>();
+
+  final _uniqueNamer = UniqueNamer();
+
+  /// Cache of values returned by [_getOrComputeImmediateSubinterfaceMap], to
+  /// avoid unnecessary recomputation.
+  final _immediateSubinterfaceCache =
+      <LibraryElement, Map<ClassElement, Set<InterfaceElement>>>{};
+
+  ApiDescription(this._pkgName, this._customizer);
+
+  /// Builds a list of [Node] objects representing all the libraries that are
+  /// relevant to the package's public API.
+  ///
+  /// This includes libraries that are in the package's public API as well as
+  /// libraries that are referenced by the package's public API (either by being
+  /// re-exported as part of the package's public API, or by being used as part
+  /// of the type of something in the public API).
+  ///
+  /// Each library node is pared with a [UriSortKey] indicating the order in
+  /// which the nodes should be output.
+  Future<List<(UriSortKey, Node)>> build(AnalysisContext context) async {
+    _customizer.packageName = _pkgName;
+    _customizer.analysisContext = context;
+    await _customizer.setupComplete();
+
+    // First, find all the libraries comprising the package's public API, and
+    // all the top level elements they export.
+    final publicApiLibraries = <LibraryElement>[];
+    final topLevelPublicElements = <Element>{};
+    for (final file in context.contextRoot.analyzedFiles().sorted()) {
+      if (!file.endsWith('.dart')) continue;
+      final someFileResult = context.currentSession.getFile(file);
+      if (someFileResult is! FileResult) continue;
+      final fileResult = someFileResult;
+      final uri = fileResult.uri;
+      if (fileResult.isLibrary && uri.isInPublicLibOf(_pkgName)) {
+        final someLibraryResult = await context.currentSession
+            .getResolvedLibrary(file);
+        if (someLibraryResult is! ResolvedLibraryResult) continue;
+        final library = someLibraryResult.element;
+        topLevelPublicElements.addAll(
+          library.exportNamespace.definedNames2.values,
+        );
+        publicApiLibraries.add(library);
+      }
+    }
+    _customizer.publicApiLibraries = publicApiLibraries;
+    _customizer.topLevelPublicElements = topLevelPublicElements;
+    await _customizer.initialScanComplete();
+
+    // Then, dump all the libraries in the package's public API.
+    final nodes = <Uri, Node<MemberSortKey>>{};
+    for (final library in publicApiLibraries) {
+      final node = nodes[library.uri] = Node<MemberSortKey>();
+      _dumpLibrary(library, node);
+    }
+
+    // Finally, dump anything referenced by those public libraries.
+    while (_potentiallyDanglingReferences.isNotEmpty) {
+      final element = _potentiallyDanglingReferences.removeFirst();
+      if (!_dumpedTopLevelElements.add(element)) continue;
+      final containingLibraryUri = element.library!.uri;
+      final childNode = Node<MemberSortKey>()
+        ..text.add(_uniqueNamer.name(element));
+      _dumpElement(element, childNode);
+      (nodes[containingLibraryUri] ??= Node<MemberSortKey>()
+            ..text.add('$containingLibraryUri:'))
+          .childNodes
+          .add((MemberSortKey(element), childNode));
+    }
+    return [
+      for (final entry in nodes.entries)
+        (UriSortKey(entry.key, _pkgName), entry.value),
+    ];
+  }
+
+  /// Creates a list of objects which, when their string representations are
+  /// concatenated, describes [type].
+  ///
+  /// The reason we use this method rather than [DartType.toString] is to make
+  /// sure that (a) every element mentioned by the type is added to
+  /// [_potentiallyDanglingReferences], and (b) if an ambiguous name is used,
+  /// the ambiguity will be taken care of by [_uniqueNamer].
+  List<Object?> _describeType(DartType type) {
+    final suffix = switch (type.nullabilitySuffix) {
+      NullabilitySuffix.none => '',
+      NullabilitySuffix.star => '*',
+      NullabilitySuffix.question => '?',
+    };
+    switch (type) {
+      case DynamicType():
+        return ['dynamic'];
+      case FunctionType(
+        :final returnType,
+        :final typeParameters,
+        :final formalParameters,
+      ):
+        final params = <List<Object?>>[];
+        final optionalParams = <List<Object?>>[];
+        final namedParams = <String, List<Object?>>{};
+        for (final formalParameter in formalParameters) {
+          if (formalParameter.isNamed) {
+            namedParams[formalParameter.name!] = [
+              if (formalParameter.isDeprecated) 'deprecated ',
+              if (formalParameter.isRequired) 'required ',
+              ..._describeType(formalParameter.type),
+            ];
+          } else if (formalParameter.isOptional) {
+            optionalParams.add([
+              if (formalParameter.isDeprecated) 'deprecated ',
+              ..._describeType(formalParameter.type),
+            ]);
+          } else {
+            params.add([
+              if (formalParameter.isDeprecated) 'deprecated ',
+              ..._describeType(formalParameter.type),
+            ]);
+          }
+        }
+        if (optionalParams.isNotEmpty) {
+          params.add(optionalParams.separatedBy(prefix: '[', suffix: ']'));
+        }
+        if (namedParams.isNotEmpty) {
+          params.add(
+            namedParams.entries
+                .sortedBy((e) => e.key)
+                .map((e) => [...e.value, ' ${e.key}'])
+                .separatedBy(prefix: '{', suffix: '}'),
+          );
+        }
+        return <Object?>[
+          ..._describeType(returnType),
+          ' Function',
+          if (typeParameters.isNotEmpty)
+            ...typeParameters
+                .map(_describeTypeParameter)
+                .separatedBy(prefix: '<', suffix: '>'),
+          '(',
+          ...params.separatedBy(),
+          ')',
+          suffix,
+        ];
+      case InterfaceType(:final element, :final typeArguments):
+        _potentiallyDanglingReferences.addLast(element);
+        return [
+          _uniqueNamer.name(element),
+          if (typeArguments.isNotEmpty)
+            ...typeArguments
+                .map(_describeType)
+                .separatedBy(prefix: '<', suffix: '>'),
+          suffix,
+        ];
+      case RecordType(:final positionalFields, :final namedFields):
+        if (positionalFields.length == 1 && namedFields.isEmpty) {
+          return [
+            '(',
+            ..._describeType(positionalFields[0].type),
+            ',)',
+            suffix,
+          ];
+        }
+        return [
+          ...[
+            for (final positionalField in positionalFields)
+              _describeType(positionalField.type),
+            if (namedFields.isNotEmpty)
+              namedFields
+                  .sortedBy((f) => f.name)
+                  .map((f) => [..._describeType(f.type), ' ', f.name])
+                  .separatedBy(prefix: '{', suffix: '}'),
+          ].separatedBy(prefix: '(', suffix: ')'),
+          suffix,
+        ];
+      case TypeParameterType(:final element):
+        return [element.name!, suffix];
+      case VoidType():
+        return ['void'];
+      case dynamic(:final runtimeType):
+        throw UnimplementedError('Unexpected type: $runtimeType');
+    }
+  }
+
+  /// Creates a list of objects which, when their string representations are
+  /// concatenated, describes [typeParameter].
+  List<Object?> _describeTypeParameter(TypeParameterElement typeParameter) => [
+    typeParameter.name!,
+    if (typeParameter.bound case final bound?) ...[
+      ' extends ',
+      ..._describeType(bound),
+    ],
+  ];
+
+  /// Appends information to [node] describing [element].
+  void _dumpElement(Element element, Node<MemberSortKey> node) {
+    final enclosingElement = element.enclosingElement;
+    if (enclosingElement is LibraryElement &&
+        !_customizer.shouldShowDetails(element)) {
+      if (!enclosingElement.uri.isIn(_pkgName)) {
+        node.text.add(' (referenced)');
+      } else {
+        node.text.add(' (non-public)');
+      }
+      return;
+    }
+    final parentheticals = <List<Object?>>[];
+    switch (element) {
+      case TypeAliasElement(:final aliasedType, :final typeParameters):
+        final description = <Object?>['type alias'];
+        if (typeParameters.isNotEmpty) {
+          description.addAll(
+            typeParameters
+                .map(_describeTypeParameter)
+                .separatedBy(prefix: '<', suffix: '>'),
+          );
+        }
+        description.addAll([' for ', ..._describeType(aliasedType)]);
+        parentheticals.add(description);
+      case InstanceElement():
+        switch (element) {
+          case InterfaceElement(
+            :final typeParameters,
+            :final supertype,
+            :final interfaces,
+          ):
+            final instanceDescription = <Object?>[
+              switch (element) {
+                ClassElement() => 'class',
+                EnumElement() => 'enum',
+                MixinElement() => 'mixin',
+                ExtensionTypeElement() => 'extension type',
+                dynamic(:final runtimeType) => 'TODO: $runtimeType',
+              },
+            ];
+            if (typeParameters.isNotEmpty) {
+              instanceDescription.addAll(
+                typeParameters
+                    .map(_describeTypeParameter)
+                    .separatedBy(prefix: '<', suffix: '>'),
+              );
+            }
+            if (element is! EnumElement && supertype != null) {
+              instanceDescription.addAll([
+                ' extends ',
+                ..._describeType(supertype),
+              ]);
+            }
+            if (element is MixinElement &&
+                element.superclassConstraints.isNotEmpty) {
+              instanceDescription.addAll(
+                element.superclassConstraints
+                    .map(_describeType)
+                    .separatedBy(prefix: ' on '),
+              );
+            }
+            if (interfaces.isNotEmpty) {
+              instanceDescription.addAll(
+                interfaces
+                    .map(_describeType)
+                    .separatedBy(prefix: ' implements '),
+              );
+            }
+            parentheticals.add(instanceDescription);
+            if (element is ClassElement) {
+              if (element.isSealed) {
+                final parenthetical = <Object>['sealed'];
+                parentheticals.add(parenthetical);
+                if (_getOrComputeImmediateSubinterfaceMap(
+                      element.library,
+                    )[element]
+                    case final subinterfaces?) {
+                  parenthetical.add(' (immediate subtypes: ');
+                  // Note: it's tempting to just do
+                  // `subinterfaces.map(_uniqueNamer.name).join(', ')`, but that
+                  // won't work, because the names returned by
+                  // `UniqueName.toString()` aren't finalized until we've
+                  // visited the entire API and seen if there are class names
+                  // that need to be disambiguated. So we accumulate the
+                  // `UniqueName` objects into the `parenthetical` list and rely
+                  // on `printNodes` converting everything to a string when the
+                  // final API description is being output.
+                  var commaNeeded = false;
+                  for (final subinterface in subinterfaces) {
+                    if (commaNeeded) {
+                      parenthetical.add(', ');
+                    } else {
+                      commaNeeded = true;
+                    }
+                    parenthetical.add(_uniqueNamer.name(subinterface));
+                  }
+                  parenthetical.add(')');
+                }
+              } else {
+                if (element.isAbstract) {
+                  parentheticals.add(['abstract']);
+                }
+                if (element.isBase) {
+                  parentheticals.add(['base']);
+                }
+                if (element.isMixinClass) {
+                  parentheticals.add(['mixin']);
+                }
+                if (element.isInterface) {
+                  parentheticals.add(['interface']);
+                }
+                if (element.isFinal) {
+                  parentheticals.add(['final']);
+                }
+              }
+            } else if (element is MixinElement) {
+              if (element.isBase) {
+                parentheticals.add(['base']);
+              }
+            }
+          case ExtensionElement(:final extendedType):
+            parentheticals.add([
+              'extension on ',
+              ..._describeType(extendedType),
+            ]);
+          case dynamic(:final runtimeType):
+            throw UnimplementedError('Unexpected element: $runtimeType');
+        }
+        for (final member in element.children.sortedBy((m) => m.name ?? '')) {
+          if (member.name case final name? when name.startsWith('_')) {
+            // Ignore private members
+            continue;
+          }
+          if (member is FieldElement) {
+            // Ignore fields; we care about the getters and setters they induce.
+            continue;
+          }
+          if (member is ConstructorElement &&
+              !member.isFactory &&
+              element is ClassElement &&
+              element.isAbstract &&
+              (element.isFinal || element.isInterface || element.isSealed)) {
+            // The class can't be constructed from outside of the library that
+            // declares it, so its generative constructors aren't part of the
+            // public API.
+            continue;
+          }
+          if (member is ConstructorElement &&
+              !member.isFactory &&
+              element is EnumElement) {
+            // Enum generative constructors can't be called from outside the
+            // enum itself, so they aren't part of the public API.
+            continue;
+          }
+          final childNode = Node<MemberSortKey>();
+          childNode.text.add(member.apiName);
+          _dumpElement(member, childNode);
+          node.childNodes.add((MemberSortKey(member), childNode));
+        }
+      case TopLevelFunctionElement(:final type):
+        parentheticals.add(['function: ', ..._describeType(type)]);
+      case ExecutableElement(:final isStatic):
+        final maybeStatic = isStatic ? 'static ' : '';
+        switch (element) {
+          case GetterElement(:final type):
+            parentheticals.add([
+              '${maybeStatic}getter: ',
+              ..._describeType(type.returnType),
+            ]);
+          case SetterElement(:final type):
+            parentheticals.add([
+              '${maybeStatic}setter: ',
+              ..._describeType(type.formalParameters.single.type),
+            ]);
+          case MethodElement(:final type):
+            parentheticals.add([
+              '${maybeStatic}method: ',
+              ..._describeType(type),
+            ]);
+          case ConstructorElement(:final type):
+            parentheticals.add(['constructor: ', ..._describeType(type)]);
+          case dynamic(:final runtimeType):
+            throw UnimplementedError('Unexpected element: $runtimeType');
+        }
+      case dynamic(:final runtimeType):
+        throw UnimplementedError('Unexpected element: $runtimeType');
+    }
+
+    // For synthetic elements such as getters/setters induced by top level
+    // variables and fields, annotations can be found on the corresponding
+    // non-synthetic element.
+    final nonSyntheticElement = element.nonSynthetic;
+    if (nonSyntheticElement.metadata.hasDeprecated) {
+      parentheticals.add(['deprecated']);
+    }
+    if (nonSyntheticElement.metadata.hasExperimental) {
+      parentheticals.add(['experimental']);
+    }
+
+    if (parentheticals.isNotEmpty) {
+      node.text.addAll(parentheticals.separatedBy(prefix: ' (', suffix: ')'));
+    }
+    if (node.childNodes.isNotEmpty) {
+      node.text.add(':');
+    }
+  }
+
+  /// Appends information to [node] describing [library].
+  void _dumpLibrary(LibraryElement library, Node<MemberSortKey> node) {
+    final uri = library.uri;
+    node.text.addAll([uri, ':']);
+    final definedNames = library.exportNamespace.definedNames2;
+    for (final key in definedNames.keys.sorted()) {
+      final element = definedNames[key]!;
+      final childNode = Node<MemberSortKey>()
+        ..text.add(_uniqueNamer.name(element));
+      if (!_dumpedTopLevelElements.add(element)) {
+        childNode.text.add(' (see above)');
+      } else {
+        _dumpElement(element, childNode);
+      }
+      node.childNodes.add((MemberSortKey(element), childNode));
+    }
+  }
+
+  /// Returns a map from each sealed class in [library] to the set of its
+  /// immediate sub-interfaces.
+  ///
+  /// If this method has been called before with the same [library], a cached
+  /// map is returned from [_immediateSubinterfaceCache]. Otherwise a fresh map
+  /// is computed.
+  Map<ClassElement, Set<InterfaceElement>>
+  _getOrComputeImmediateSubinterfaceMap(LibraryElement library) {
+    if (_immediateSubinterfaceCache[library] case final m?) return m;
+    final result = <ClassElement, Set<InterfaceElement>>{};
+    for (final interface in [
+      ...library.classes,
+      ...library.mixins,
+      ...library.enums,
+      ...library.extensionTypes,
+    ]..sortBy((e) => e.name!)) {
+      for (final superinterface in [
+        interface.supertype,
+        ...interface.interfaces,
+        ...interface.mixins,
+        if (interface is MixinElement) ...interface.superclassConstraints,
+      ]) {
+        if (superinterface == null) continue;
+        final superinterfaceElement = superinterface.element;
+        if (superinterfaceElement is ClassElement &&
+            superinterfaceElement.isSealed) {
+          (result[superinterfaceElement] ??= {}).add(interface);
+        }
+      }
+    }
+    _immediateSubinterfaceCache[library] = result;
+    return result;
+  }
+}
diff --git a/pkgs/api_summary/lib/src/api_summary_customizer.dart b/pkgs/api_summary/lib/src/api_summary_customizer.dart
new file mode 100644
index 0000000..34c6845
--- /dev/null
+++ b/pkgs/api_summary/lib/src/api_summary_customizer.dart
@@ -0,0 +1,53 @@
+// Copyright (c) 2026, 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/analysis/analysis_context.dart';
+import 'package:analyzer/dart/element/element.dart';
+
+/// Clients of the API summary tool may extend this class to customize its
+/// behavior.
+///
+/// Clients should not *implement* this class, however, because additional
+/// methods may be added in the future.
+base class ApiSummaryCustomizer {
+  /// The top level elements exported by the libraries in [publicApiLibraries].
+  ///
+  /// This value is set by the tool before [initialScanComplete] is called.
+  late final Set<Element> topLevelPublicElements;
+
+  /// The analysis context for the package being summarized.
+  ///
+  /// This value is set by the tool before [setupComplete] is called.
+  set analysisContext(AnalysisContext analysisContext) {}
+
+  /// The name of the package whose API is being summarized.
+  ///
+  /// This value is set by the tool before [setupComplete] is called.
+  set packageName(String value) {}
+
+  /// The libraries that comprise the package's public API.
+  ///
+  /// This value is set by the tool before [initialScanComplete] is called.
+  set publicApiLibraries(Iterable<LibraryElement> value) {}
+
+  /// Called after [publicApiLibraries] and [topLevelPublicElements] have been
+  /// set, but before any analysis has been performed.
+  ///
+  /// Further analysis won't be performed until the returned Future completes.
+  Future<void> initialScanComplete() async {}
+
+  /// Called after [packageName] and [analysisContext] have been set, but before
+  /// any analysis has been performed.
+  ///
+  /// The initial scan won't be performed until the returned Future completes.
+  Future<void> setupComplete() async {}
+
+  /// Called after [initialScanComplete] to determine if details about an
+  /// element should be shown in the API summary.
+  ///
+  /// The default behavior is to show details about elements in
+  /// [topLevelPublicElements].
+  bool shouldShowDetails(Element element) =>
+      topLevelPublicElements.contains(element);
+}
diff --git a/pkgs/api_summary/lib/src/extensions.dart b/pkgs/api_summary/lib/src/extensions.dart
new file mode 100644
index 0000000..a683382
--- /dev/null
+++ b/pkgs/api_summary/lib/src/extensions.dart
@@ -0,0 +1,68 @@
+// Copyright (c) 2026, 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/element/element.dart';
+
+extension ElementExtension on Element {
+  /// Returns the appropriate name for describing the element in `api.txt`.
+  ///
+  /// The name is the same as [name], but with `=` appended for setters.
+  String get apiName {
+    var apiName = name!;
+    if (this is SetterElement) {
+      apiName += '=';
+    }
+    return apiName;
+  }
+}
+
+extension FormalParameterElementExtension on FormalParameterElement {
+  bool get isDeprecated =>
+      // TODO(paulberry): add this to the analyzer public API
+      metadata.hasDeprecated;
+}
+
+extension IterableIterableExtension on Iterable<Iterable<Object?>> {
+  /// Forms a list containing [prefix], followed by the elements of `this`
+  /// (separated by [separator]), followed by [suffix].
+  ///
+  /// Each element of `this` is also an iterable; these elements are added to
+  /// the resulting list using `.addAll`, so one level of iterable nesting is
+  /// removed.
+  List<Object?> separatedBy({
+    String separator = ', ',
+    String prefix = '',
+    String suffix = '',
+  }) {
+    final result = <Object?>[prefix];
+    var first = true;
+    for (final item in this) {
+      if (first) {
+        first = false;
+      } else {
+        result.add(separator);
+      }
+      result.addAll(item);
+    }
+    result.add(suffix);
+    return result;
+  }
+}
+
+extension StringExtension on String {
+  bool get isPublic => !startsWith('_');
+}
+
+extension UriExtension on Uri {
+  bool isIn(String packageName) =>
+      scheme == 'package' &&
+      pathSegments.isNotEmpty &&
+      pathSegments[0] == packageName;
+
+  bool isInPublicLibOf(String packageName) =>
+      scheme == 'package' &&
+      pathSegments.length > 1 &&
+      pathSegments[0] == packageName &&
+      pathSegments[1] != 'src';
+}
diff --git a/pkgs/api_summary/lib/src/member_sorting.dart b/pkgs/api_summary/lib/src/member_sorting.dart
new file mode 100644
index 0000000..f53b0cc
--- /dev/null
+++ b/pkgs/api_summary/lib/src/member_sorting.dart
@@ -0,0 +1,67 @@
+// Copyright (c) 2026, 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/element/element.dart';
+
+/// Element categorization used by [MemberSortKey].
+enum MemberCategory {
+  constructor,
+  propertyAccessor,
+  topLevelFunctionOrMethod,
+  interface,
+  extension,
+  typeAlias,
+}
+
+/// Sort key used to sort elements in the output.
+class MemberSortKey implements Comparable<MemberSortKey> {
+  final bool _isInstanceMember;
+  final MemberCategory _category;
+  final String _name;
+  final bool _isSetter;
+
+  MemberSortKey(Element element)
+    : _isInstanceMember = _computeIsInstanceMember(element),
+      _category = _computeCategory(element),
+      _name = element.displayName,
+      _isSetter = element is SetterElement;
+
+  @override
+  int compareTo(MemberSortKey other) {
+    if ((_isInstanceMember ? 1 : 0).compareTo(other._isInstanceMember ? 1 : 0)
+        case final value when value != 0) {
+      return value;
+    }
+    if (_category.index.compareTo(other._category.index) case final value
+        when value != 0) {
+      return value;
+    }
+    if (_name.compareTo(other._name) case final value when value != 0) {
+      return value;
+    }
+    return (_isSetter ? 1 : 0).compareTo(other._isSetter ? 1 : 0);
+  }
+
+  static MemberCategory _computeCategory(Element element) => switch (element) {
+    ConstructorElement() => MemberCategory.constructor,
+    PropertyAccessorElement() => MemberCategory.propertyAccessor,
+    TopLevelFunctionElement() => MemberCategory.topLevelFunctionOrMethod,
+    MethodElement() => MemberCategory.topLevelFunctionOrMethod,
+    InterfaceElement() => MemberCategory.interface,
+    ExtensionElement() => MemberCategory.extension,
+    TypeAliasElement() => MemberCategory.typeAlias,
+    dynamic(:final runtimeType) => throw UnimplementedError(
+      'Unexpected element: $runtimeType',
+    ),
+  };
+
+  static bool _computeIsInstanceMember(Element element) =>
+      element.enclosingElement is InstanceElement &&
+      switch (element) {
+        ExecutableElement(:final isStatic) => !isStatic,
+        dynamic(:final runtimeType) => throw UnimplementedError(
+          'Unexpected element: $runtimeType',
+        ),
+      };
+}
diff --git a/pkgs/api_summary/lib/src/node.dart b/pkgs/api_summary/lib/src/node.dart
new file mode 100644
index 0000000..083d037
--- /dev/null
+++ b/pkgs/api_summary/lib/src/node.dart
@@ -0,0 +1,43 @@
+// Copyright (c) 2026, 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.
+
+/// @docImport 'unique_namer.dart';
+library;
+
+import 'package:collection/collection.dart';
+
+/// Outputs the contents of [nodes] to [sink], prepending [prefix] to every
+/// line.
+void printNodes<SortKey extends Comparable<SortKey>>(
+  StringSink sink,
+  List<(SortKey, Node)> nodes, {
+  String prefix = '',
+}) {
+  for (final entry in nodes.sortedBy((n) => n.$1)) {
+    final node = entry.$2;
+    sink.writeln('$prefix${node.text.join()}');
+    node.printChildren(sink, prefix: '$prefix  ');
+  }
+}
+
+/// A node to be printed to the output.
+class Node<ChildSortKey extends Comparable<ChildSortKey>> {
+  /// A list of objects which, when their string representations are
+  /// concatenated, is the text that should be displayed on the first line of
+  /// the node.
+  ///
+  /// The reason this is a list rather than a single string is to allow elements
+  /// of the list to be [UniqueName] objects, which may acquire a disambiguation
+  /// suffix at a later time.
+  final text = <Object?>[];
+
+  /// A list of child nodes, paired with a sort key indicating the order in
+  /// which they should be output.
+  final childNodes = <(ChildSortKey, Node)>[];
+
+  /// Outputs [childNodes], prepending [prefix] to every line.
+  void printChildren(StringSink sink, {required String prefix}) {
+    printNodes(sink, childNodes, prefix: prefix);
+  }
+}
diff --git a/pkgs/api_summary/lib/src/unique_namer.dart b/pkgs/api_summary/lib/src/unique_namer.dart
new file mode 100644
index 0000000..ccd59dc
--- /dev/null
+++ b/pkgs/api_summary/lib/src/unique_namer.dart
@@ -0,0 +1,53 @@
+// Copyright (c) 2026, 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/element/element.dart';
+import 'extensions.dart';
+
+/// Object that will have a unique string representation within the context of a
+/// given [UniqueNamer] instance.
+///
+/// If two or more [UniqueName] objects are constructed with reference to the
+/// same [UniqueNamer], and they have the same [_nameHint], then all such
+/// objects' [toString] methods will append a unique suffix of the form
+/// `@INTEGER`, so that the resulting strings are unique.
+class UniqueName {
+  /// The name that will be returned by [toString] if no disambiguation is
+  /// needed.
+  final String _nameHint;
+
+  /// If not `Null`, the integer that [toString] will use to disambiguate this
+  /// [UniqueName] from other ones with the same [_nameHint].
+  int? _disambiguator;
+
+  UniqueName(UniqueNamer uniqueNamer, this._nameHint)
+    // The uniqueness guarantee depends on `_nameHint` not containing an `@`.
+    : assert(!_nameHint.contains('@')) {
+    final conflicts = uniqueNamer._conflicts[_nameHint] ??= [];
+    if (conflicts.length == 1) {
+      conflicts[0]._disambiguator = 1;
+    }
+    conflicts.add(this);
+    if (conflicts.length > 1) {
+      _disambiguator = conflicts.length;
+    }
+  }
+
+  @override
+  String toString() => [
+    _nameHint,
+    if (_disambiguator case final disambiguator?) '@$disambiguator',
+  ].join();
+}
+
+/// Manager of unique names for elements.
+class UniqueNamer {
+  final _names = <Element, UniqueName>{};
+  final _conflicts = <String, List<UniqueName>>{};
+
+  /// Returns a [UniqueName] object whose [toString] method will produce a
+  /// unique name for [element].
+  UniqueName name(Element element) =>
+      _names[element] ??= UniqueName(this, element.apiName);
+}
diff --git a/pkgs/api_summary/lib/src/uri_sorting.dart b/pkgs/api_summary/lib/src/uri_sorting.dart
new file mode 100644
index 0000000..1f84462
--- /dev/null
+++ b/pkgs/api_summary/lib/src/uri_sorting.dart
@@ -0,0 +1,32 @@
+// Copyright (c) 2026, 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 'extensions.dart';
+
+/// URI categorization used by [UriSortKey].
+enum UriCategory { inPackage, notInPackage }
+
+/// Sort key used to sort libraries in the output.
+///
+/// Libraries in the specified package will be output first (sorted by URI),
+/// followed by libraries not in the package.
+class UriSortKey implements Comparable<UriSortKey> {
+  final UriCategory _category;
+  final String _uriString;
+
+  UriSortKey(Uri uri, String pkgName)
+    : _category = uri.isIn(pkgName)
+          ? UriCategory.inPackage
+          : UriCategory.notInPackage,
+      _uriString = uri.toString();
+
+  @override
+  int compareTo(UriSortKey other) {
+    if (_category.index.compareTo(other._category.index) case final value
+        when value != 0) {
+      return value;
+    }
+    return _uriString.compareTo(other._uriString);
+  }
+}
diff --git a/pkgs/api_summary/pubspec.yaml b/pkgs/api_summary/pubspec.yaml
new file mode 100644
index 0000000..b664c63
--- /dev/null
+++ b/pkgs/api_summary/pubspec.yaml
@@ -0,0 +1,22 @@
+name: api_summary
+version: 0.1.0-wip
+description: Creates an API summary for a package.
+
+environment:
+  sdk: ^3.12.0
+
+dependencies:
+  analyzer: ^13.0.0
+  args: ^2.6.0
+  collection: ^1.19.0
+  path: ^1.9.0
+  yaml: ^3.1.2
+
+dev_dependencies:
+  analyzer_testing: ^0.2.6
+  dart_flutter_team_lints: ^3.0.0
+  test: ^1.28.0
+  test_reflective_loader: ^0.4.0
+
+executables:
+  api_summary:
diff --git a/pkgs/api_summary/test/api_description_test.dart b/pkgs/api_summary/test/api_description_test.dart
new file mode 100644
index 0000000..0fa98b4
--- /dev/null
+++ b/pkgs/api_summary/test/api_description_test.dart
@@ -0,0 +1,820 @@
+// Copyright (c) 2026, 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: non_constant_identifier_names
+
+import 'dart:core';
+
+import 'package:analyzer/dart/analysis/analysis_context.dart';
+import 'package:analyzer/dart/element/element.dart';
+import 'package:api_summary/src/api_description.dart';
+import 'package:api_summary/src/api_summary_customizer.dart';
+import 'package:api_summary/src/node.dart';
+import 'package:test/test.dart';
+import 'package:test_reflective_loader/test_reflective_loader.dart';
+
+import 'test_utils.dart';
+
+void main() {
+  defineReflectiveSuite(() {
+    defineReflectiveTests(ApiDescriptionTest);
+  });
+}
+
+@reflectiveTest
+class ApiDescriptionTest extends ApiSummaryTest {
+  @override
+  bool get addMetaPackageDep => true;
+
+  @override
+  void setUp() {
+    newPackage('foo').addFile('lib/foo.dart', r'''
+foo() {}
+class Foo {}
+''');
+    super.setUp();
+  }
+
+  Future<void> test_class_modifiers() async {
+    final summary = await _build({
+      '$testPackageLibPath/file.dart': '''
+class C {}
+abstract class A {}
+base class B {}
+base mixin class BM {}
+mixin class M {}
+interface class I {}
+final class F {}
+abstract base class AB {}
+abstract base mixin class ABM {}
+abstract interface class AI {}
+abstract final class AF {}
+abstract mixin class AM {}
+''',
+    });
+    expect(summary, '''
+package:test/file.dart:
+  A (class extends Object, abstract):
+    new (constructor: A Function())
+  AB (class extends Object, abstract, base):
+    new (constructor: AB Function())
+  ABM (class extends Object, abstract, base, mixin):
+    new (constructor: ABM Function())
+  AF (class extends Object, abstract, final)
+  AI (class extends Object, abstract, interface)
+  AM (class extends Object, abstract, mixin):
+    new (constructor: AM Function())
+  B (class extends Object, base):
+    new (constructor: B Function())
+  BM (class extends Object, base, mixin):
+    new (constructor: BM Function())
+  C (class extends Object):
+    new (constructor: C Function())
+  F (class extends Object, final):
+    new (constructor: F Function())
+  I (class extends Object, interface):
+    new (constructor: I Function())
+  M (class extends Object, mixin):
+    new (constructor: M Function())
+dart:core:
+  Object (referenced)
+''');
+  }
+
+  Future<void> test_customize_shouldShowDetails() async {
+    final summary = await _build(
+      {
+        '$testPackageLibPath/public.dart': '''
+import 'src/private.dart';
+
+void shown1(Shown2 x, Hidden2 y) {}
+void hidden1(Shown3 x, Hidden3 y) {}
+''',
+        '$testPackageLibPath/src/private.dart': '''
+class Shown2 {}
+class Hidden2 {}
+class Shown3 {}
+class Hidden3 {}
+''',
+      },
+      createCustomizer: () => _ShouldShowDetailsCustomizer(
+        (e) => e.name!.toLowerCase().contains('shown'),
+      ),
+    );
+    // Note: Shown2 and Hidden2 are included in the summary because they are
+    // referenced by shown1. Details are only shown for shown1 and Shown2.
+    expect(summary, '''
+package:test/public.dart:
+  hidden1 (non-public)
+  shown1 (function: void Function(Shown2, Hidden2))
+package:test/src/private.dart:
+  Hidden2 (non-public)
+  Shown2 (class extends Object):
+    new (constructor: Shown2 Function())
+dart:core:
+  Object (referenced)
+''');
+  }
+
+  Future<void> test_field_deprecated() async {
+    // Marking a field as deprecated causes its corresponding getter and setter
+    // to be marked as deprecated in the summary.
+    final summary = await _build({
+      '$testPackageLibPath/file.dart': '''
+class C {
+  @deprecated
+  int x = 0;
+}
+''',
+    });
+    expect(summary, '''
+package:test/file.dart:
+  C (class extends Object):
+    new (constructor: C Function())
+    x (getter: int, deprecated)
+    x= (setter: int, deprecated)
+dart:core:
+  Object (referenced)
+  int (referenced)
+''');
+  }
+
+  Future<void> test_field_experimental() async {
+    // Marking a field as experimental causes its corresponding getter and
+    // setter to be marked as experimental in the summary.
+    final summary = await _build({
+      '$testPackageLibPath/file.dart': '''
+import 'package:meta/meta.dart';
+
+class C {
+  @experimental
+  int x = 0;
+}
+''',
+    });
+    expect(summary, '''
+package:test/file.dart:
+  C (class extends Object):
+    new (constructor: C Function())
+    x (getter: int, experimental)
+    x= (setter: int, experimental)
+dart:core:
+  Object (referenced)
+  int (referenced)
+''');
+  }
+
+  Future<void> test_member_field() async {
+    final summary = await _build({
+      '$testPackageLibPath/file.dart': '''
+class C {
+  int x = 0;
+}
+''',
+    });
+    // The summary output contains the getters and setters induced by the field,
+    // not the field itself.
+    expect(summary, '''
+package:test/file.dart:
+  C (class extends Object):
+    new (constructor: C Function())
+    x (getter: int)
+    x= (setter: int)
+dart:core:
+  Object (referenced)
+  int (referenced)
+''');
+  }
+
+  Future<void> test_member_getterSetterPair() async {
+    // This test verifies that even if a getter and a setter have the same name,
+    // both are included in the summary output.
+    final summary = await _build({
+      '$testPackageLibPath/file.dart': '''
+class C {
+  get x => 0;
+  set x(value) {}
+}
+''',
+    });
+    expect(summary, '''
+package:test/file.dart:
+  C (class extends Object):
+    new (constructor: C Function())
+    x (getter: dynamic)
+    x= (setter: dynamic)
+dart:core:
+  Object (referenced)
+''');
+  }
+
+  Future<void> test_member_method() async {
+    final summary = await _build({
+      '$testPackageLibPath/file.dart': '''
+class C {
+  void f() {}
+}
+''',
+    });
+    expect(summary, '''
+package:test/file.dart:
+  C (class extends Object):
+    new (constructor: C Function())
+    f (method: void Function())
+dart:core:
+  Object (referenced)
+''');
+  }
+
+  Future<void> test_member_privateName() async {
+    final summary = await _build({
+      '$testPackageLibPath/file.dart': '''
+class C {
+  f() {
+    _f();
+  }
+  _f() {}
+}
+''',
+    });
+    // The private member _f is not included in the summary output.
+    expect(summary, '''
+package:test/file.dart:
+  C (class extends Object):
+    new (constructor: C Function())
+    f (method: dynamic Function())
+dart:core:
+  Object (referenced)
+''');
+  }
+
+  Future<void> test_minimallyDescribesReferencedNamesInOtherPackages() async {
+    final summary = await _build({
+      '$testPackageLibPath/file.dart': '''
+import 'package:foo/foo.dart';
+
+void f(Foo foo) {}
+''',
+    });
+    expect(summary, '''
+package:test/file.dart:
+  f (function: void Function(Foo))
+package:foo/foo.dart:
+  Foo (referenced)
+''');
+  }
+
+  Future<void> test_minimallyDescribesReferencedNonPublicNames() async {
+    final summary = await _build({
+      '$testPackageLibPath/public.dart': '''
+import 'src/private.dart';
+
+void f(Foo foo) {}
+''',
+      '$testPackageLibPath/src/private.dart': 'class Foo {}',
+    });
+    expect(summary, '''
+package:test/public.dart:
+  f (function: void Function(Foo))
+package:test/src/private.dart:
+  Foo (non-public)
+''');
+  }
+
+  Future<void> test_mixin_modifiers() async {
+    final summary = await _build({
+      '$testPackageLibPath/file.dart': '''
+mixin M {}
+base mixin BM {}
+''',
+    });
+    expect(summary, '''
+package:test/file.dart:
+  BM (mixin on Object, base)
+  M (mixin on Object)
+dart:core:
+  Object (referenced)
+''');
+  }
+
+  Future<void> test_nonConstructibleClass() async {
+    final summary = await _build({
+      '$testPackageLibPath/file.dart': '''
+abstract final class C1 {}
+abstract interface class C2 {}
+sealed class C3 {}
+''',
+    });
+    // These classes can't be constructed from outside the library, so their
+    // constructors aren't included in the summary output.
+    expect(summary, '''
+package:test/file.dart:
+  C1 (class extends Object, abstract, final)
+  C2 (class extends Object, abstract, interface)
+  C3 (class extends Object, sealed)
+dart:core:
+  Object (referenced)
+''');
+  }
+
+  Future<void> test_nonConstructibleClass_withFactory() async {
+    final summary = await _build({
+      '$testPackageLibPath/file.dart': '''
+abstract final class C1 {
+  factory C1.f() => throw '';
+}
+abstract interface class C2 {
+  factory C2.f() => throw '';
+}
+sealed class C3 {
+  factory C3.f() => throw '';
+}
+''',
+    });
+    expect(summary, '''
+package:test/file.dart:
+  C1 (class extends Object, abstract, final):
+    f (constructor: C1 Function())
+  C2 (class extends Object, abstract, interface):
+    f (constructor: C2 Function())
+  C3 (class extends Object, sealed):
+    f (constructor: C3 Function())
+dart:core:
+  Object (referenced)
+''');
+  }
+
+  Future<void> test_enum_withFactory() async {
+    final summary = await _build({
+      '$testPackageLibPath/file.dart': '''
+enum E {
+  v;
+  factory E.f() => throw '';
+}
+''',
+    });
+    expect(summary, '''
+package:test/file.dart:
+  E (enum):
+    v (static getter: E)
+    values (static getter: List<E>)
+    f (constructor: E Function())
+dart:core:
+  List (referenced)
+''');
+  }
+
+  Future<void> test_sealedClass() async {
+    final summary = await _build({
+      '$testPackageLibPath/file.dart': '''
+sealed class C {}
+// Note: immediate subinterfaces will be sorted in summary output
+class C2 extends C {}
+class C1 extends C {}
+''',
+    });
+    expect(summary, '''
+package:test/file.dart:
+  C (class extends Object, sealed (immediate subtypes: C1, C2))
+  C1 (class extends C):
+    new (constructor: C1 Function())
+  C2 (class extends C):
+    new (constructor: C2 Function())
+dart:core:
+  Object (referenced)
+''');
+  }
+
+  Future<void> test_sealedClass_allKindsAndRelationships() async {
+    final summary = await _build({
+      '$testPackageLibPath/file.dart': '''
+sealed class S {}
+class C1 extends S {}
+class C2 implements S {}
+mixin M1 on S {}
+mixin M2 implements S {}
+enum E1 implements S { v }
+extension type T(C1 c) implements S {}
+''',
+    });
+    expect(summary, '''
+package:test/file.dart:
+  C1 (class extends S):
+    new (constructor: C1 Function())
+  C2 (class extends Object implements S):
+    new (constructor: C2 Function())
+  E1 (enum implements S):
+    v (static getter: E1)
+    values (static getter: List<E1>)
+  M1 (mixin on S)
+  M2 (mixin on Object implements S)
+  S (class extends Object, sealed (immediate subtypes: C1, C2, E1, M1, M2, T))
+  T (extension type implements S):
+    new (constructor: T Function(C1))
+    c (getter: C1)
+dart:core:
+  List (referenced)
+  Object (referenced)
+''');
+  }
+
+  Future<void> test_topLevel_collapsesRedundantElements() async {
+    // When an element is exported by multiple libraries, it is only described
+    // once; later references use the text "(see above)".
+    final summary = await _build({
+      '$testPackageLibPath/file1.dart': 'export "file2.dart";',
+      '$testPackageLibPath/file2.dart': 'class C {}',
+      '$testPackageLibPath/file3.dart': 'export "file2.dart";',
+    });
+    expect(summary, '''
+package:test/file1.dart:
+  C (class extends Object):
+    new (constructor: C Function())
+package:test/file2.dart:
+  C (see above)
+package:test/file3.dart:
+  C (see above)
+dart:core:
+  Object (referenced)
+''');
+  }
+
+  Future<void> test_topLevel_disambiguatesNames() async {
+    // If two libraries declare top level elements with the same name, the names
+    // are disambiguated so that references are clear.
+    final summary = await _build({
+      '$testPackageLibPath/file1.dart': '''
+class A {}
+class B extends A {}
+''',
+      '$testPackageLibPath/file2.dart': '''
+class A {}
+class B extends A {}
+''',
+    });
+    expect(summary, '''
+package:test/file1.dart:
+  A@1 (class extends Object):
+    new (constructor: A@1 Function())
+  B@1 (class extends A@1):
+    new (constructor: B@1 Function())
+package:test/file2.dart:
+  A@2 (class extends Object):
+    new (constructor: A@2 Function())
+  B@2 (class extends A@2):
+    new (constructor: B@2 Function())
+dart:core:
+  Object (referenced)
+''');
+  }
+
+  Future<void> test_topLevel_extension() async {
+    final summary = await _build({
+      '$testPackageLibPath/file.dart': '''
+extension E on int {
+  void f() {}
+}
+''',
+    });
+    expect(summary, '''
+package:test/file.dart:
+  E (extension on int):
+    f (method: void Function())
+dart:core:
+  int (referenced)
+''');
+  }
+
+  Future<void> test_topLevel_filesInSrc() async {
+    final summary = await _build({
+      '$testPackageLibPath/public.dart': 'export "src/private1.dart";',
+      '$testPackageLibPath/src/private1.dart': 'f() {}',
+      '$testPackageLibPath/src/private2.dart': 'g() {}',
+    });
+    // `f` is considered part of the public API because it is exported by
+    // `public.dart`.
+    expect(summary, '''
+package:test/public.dart:
+  f (function: dynamic Function())
+''');
+  }
+
+  Future<void> test_topLevel_getterSetterPair() async {
+    // This test verifies that even if getter and a setter have the same name,
+    // both are included in the summary output.
+    final summary = await _build({
+      '$testPackageLibPath/file.dart': '''
+get x => 0;
+set x(value) {}
+''',
+    });
+    expect(summary, '''
+package:test/file.dart:
+  x (static getter: dynamic)
+  x= (static setter: dynamic)
+''');
+  }
+
+  Future<void> test_topLevel_interfaceType() async {
+    final summary = await _build({
+      '$testPackageLibPath/file.dart': '''
+class I {}
+class B {}
+class C<T> extends B implements I {}
+enum E implements I { e1 }
+mixin M on B implements I {}
+extension type T(int i) {}
+''',
+    });
+    expect(summary, '''
+package:test/file.dart:
+  B (class extends Object):
+    new (constructor: B Function())
+  C (class<T> extends B implements I):
+    new (constructor: C<T> Function())
+  E (enum implements I):
+    e1 (static getter: E)
+    values (static getter: List<E>)
+  I (class extends Object):
+    new (constructor: I Function())
+  M (mixin on B implements I)
+  T (extension type):
+    new (constructor: T Function(int))
+    i (getter: int)
+dart:core:
+  List (referenced)
+  Object (referenced)
+  int (referenced)
+''');
+  }
+
+  Future<void> test_topLevel_nonDartFile() async {
+    final summary = await _build({'$testPackageLibPath/file.dar': 'f() {}'});
+    expect(summary, '');
+  }
+
+  Future<void> test_topLevel_otherPackagePublicApi() async {
+    final summary = await _build({
+      '$testPackageLibPath/file.dart': '''
+import 'package:foo/foo.dart';
+
+main() {
+  foo();
+}
+''',
+    });
+    expect(summary, '''
+package:test/file.dart:
+  main (function: dynamic Function())
+''');
+  }
+
+  Future<void> test_topLevel_partFile() async {
+    // Declarations in a part file are considered part of the public API of the
+    // containing library.
+    final summary = await _build({
+      '$testPackageLibPath/lib.dart': 'part "part.dart";',
+      '$testPackageLibPath/part.dart': '''
+part of "lib.dart";
+
+f() {}
+''',
+    });
+    expect(summary, '''
+package:test/lib.dart:
+  f (function: dynamic Function())
+''');
+  }
+
+  Future<void> test_topLevel_privateName() async {
+    final summary = await _build({
+      '$testPackageLibPath/file.dart': '''
+f() {
+  _g();
+}
+
+_g() {}
+''',
+    });
+    // The private member _g is not included in the summary output.
+    expect(summary, '''
+package:test/file.dart:
+  f (function: dynamic Function())
+''');
+  }
+
+  Future<void> test_topLevel_sorted() async {
+    // This test just verifies that sorting occurs. See `member_test.dart` for
+    // tests of the precise nature of the sort order.
+    final summary = await _build({
+      '$testPackageLibPath/file.dart': '''
+g() {}
+f() {}
+get x => 0;
+class C {}
+''',
+    });
+    expect(summary, '''
+package:test/file.dart:
+  x (static getter: dynamic)
+  f (function: dynamic Function())
+  g (function: dynamic Function())
+  C (class extends Object):
+    new (constructor: C Function())
+dart:core:
+  Object (referenced)
+''');
+  }
+
+  Future<void> test_topLevel_sortsLibrariesByUri() async {
+    final summary = await _build({
+      '$testPackageLibPath/file2.dart': '',
+      '$testPackageLibPath/file1.dart': '',
+      '$testPackageLibPath/file3.dart': '',
+    });
+    expect(summary, '''
+package:test/file1.dart:
+package:test/file2.dart:
+package:test/file3.dart:
+''');
+  }
+
+  Future<void> test_topLevel_typedef() async {
+    final summary = await _build({
+      '$testPackageLibPath/file.dart': '''
+typedef void oldStyleFunctionTypedef();
+typedef void oldStyleFunctionTypedefGeneric<T>(T t);
+typedef newStyleFunctionTypedef = void Function();
+typedef newStyleFunctionTypedefGeneric = void Function<T>(T);
+typedef nonFunctionTypedef = int;
+''',
+    });
+    expect(summary, '''
+package:test/file.dart:
+  newStyleFunctionTypedef (type alias for void Function())
+  newStyleFunctionTypedefGeneric (type alias for void Function<T>(T))
+  nonFunctionTypedef (type alias for int)
+  oldStyleFunctionTypedef (type alias for void Function())
+  oldStyleFunctionTypedefGeneric (type alias<T> for void Function(T))
+dart:core:
+  int (referenced)
+''');
+  }
+
+  Future<void> test_types() async {
+    final summary = await _build({
+      '$testPackageLibPath/file.dart': '''
+import 'dart:async';
+
+// Dynamic type
+dynamic get d => 0;
+
+// Null type
+Null get n => null;
+
+// FutureOr type
+FutureOr<int>? get fo => null;
+
+// Function types
+void Function(int requiredPositionalParam, [int? optionalPositionalParam])
+    get f1 => throw '';
+void Function({
+    // Note: named params will be sorted by name
+    required int requiredNamedParam, int? optionalNamedParam})? get f2 => null;
+void f3(@deprecated int i, [@deprecated int? j]) {}
+void f4({@deprecated int? i}) {}
+void f5<T>(T t1, T? t2) {} // Also tests type parameter types
+void f6<T extends num>(T t) {}
+
+// Interface types
+void f7(Map<String, int> m1, Map<String, int>? m2) {}
+
+// Record types
+void f8((int, String) r1, (int, {String s})? r2,
+    // Note: named record fields will be sorted by name
+    ({String s, int i}) r3) {}
+''',
+    });
+    expect(summary, '''
+package:test/file.dart:
+  d (static getter: dynamic)
+  f1 (static getter: void Function(int, [int?]))
+  f2 (static getter: void Function({int? optionalNamedParam, required int requiredNamedParam})?)
+  fo (static getter: FutureOr<int>?)
+  n (static getter: Null)
+  f3 (function: void Function(deprecated int, [deprecated int?]))
+  f4 (function: void Function({deprecated int? i}))
+  f5 (function: void Function<T>(T, T?))
+  f6 (function: void Function<T extends num>(T))
+  f7 (function: void Function(Map<String, int>, Map<String, int>?))
+  f8 (function: void Function((int, String), (int, {String s})?, ({int i, String s})))
+dart:async:
+  FutureOr (referenced)
+dart:core:
+  Map (referenced)
+  Null (referenced)
+  String (referenced)
+  int (referenced)
+  num (referenced)
+''');
+  }
+
+  Future<String> _build(
+    Map<String, String> files, {
+    _ValidatingCustomizer Function()? createCustomizer,
+  }) async {
+    // Create all the files.
+    files.forEach(newFile);
+
+    // As a sanity check, make sure there are no errors in any of the files.
+    for (final file in files.keys) {
+      if (file.endsWith('.dart')) await assertNoDiagnosticsInFile(file);
+    }
+
+    // Generate the API description.
+    final context = contextCollection.contextFor(
+      convertPath(testPackageLibPath),
+    );
+    final customizer = createCustomizer?.call() ?? _ValidatingCustomizer();
+    final apiDescription = ApiDescription('test', customizer);
+    final stringBuffer = StringBuffer();
+    final nodes = await apiDescription.build(context);
+    expect(customizer.initialScanCompleteCalled, isTrue);
+    printNodes(stringBuffer, nodes);
+    return stringBuffer.toString();
+  }
+}
+
+final class _ShouldShowDetailsCustomizer extends _ValidatingCustomizer {
+  final bool Function(Element) _shouldShowDetails;
+
+  _ShouldShowDetailsCustomizer(this._shouldShowDetails);
+
+  @override
+  bool shouldShowDetails(Element element) {
+    expect(initialScanCompleteCalled, isTrue);
+    return _shouldShowDetails(element);
+  }
+}
+
+base class _ValidatingCustomizer extends ApiSummaryCustomizer {
+  bool topLevelPublicElementsCalled = false;
+  bool analysisContextCalled = false;
+  bool packageNameCalled = false;
+  bool publicApiLibrariesCalled = false;
+  bool initialScanCompleteCalled = false;
+  bool setupCompleteCalled = false;
+
+  @override
+  set analysisContext(AnalysisContext value) {
+    expect(analysisContextCalled, isFalse);
+    analysisContextCalled = true;
+    super.analysisContext = value;
+  }
+
+  @override
+  set packageName(String value) {
+    expect(packageNameCalled, isFalse);
+    packageNameCalled = true;
+    super.packageName = value;
+  }
+
+  @override
+  set publicApiLibraries(Iterable<LibraryElement> value) {
+    expect(setupCompleteCalled, isTrue);
+    expect(publicApiLibrariesCalled, isFalse);
+    publicApiLibrariesCalled = true;
+    super.publicApiLibraries = value;
+  }
+
+  @override
+  set topLevelPublicElements(Set<Element> value) {
+    expect(setupCompleteCalled, isTrue);
+    expect(topLevelPublicElementsCalled, isFalse);
+    topLevelPublicElementsCalled = true;
+    super.topLevelPublicElements = value;
+  }
+
+  @override
+  Future<void> initialScanComplete() async {
+    expect(topLevelPublicElementsCalled, isTrue);
+    expect(publicApiLibrariesCalled, isTrue);
+    initialScanCompleteCalled = true;
+    await super.initialScanComplete();
+  }
+
+  @override
+  Future<void> setupComplete() async {
+    expect(packageNameCalled, isTrue);
+    expect(analysisContextCalled, isTrue);
+    setupCompleteCalled = true;
+    await super.setupComplete();
+  }
+
+  @override
+  bool shouldShowDetails(Element element) {
+    expect(initialScanCompleteCalled, isTrue);
+    return super.shouldShowDetails(element);
+  }
+}
diff --git a/pkgs/api_summary/test/app_test.dart b/pkgs/api_summary/test/app_test.dart
new file mode 100644
index 0000000..5ea6390
--- /dev/null
+++ b/pkgs/api_summary/test/app_test.dart
@@ -0,0 +1,42 @@
+// Copyright (c) 2026, 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 'dart:io';
+import 'package:path/path.dart' as p;
+import 'package:test/test.dart';
+
+void main() {
+  test(
+    'api_summary output matches api.txt',
+    timeout: const Timeout.factor(3),
+    () async {
+      final packageDir = p.current;
+
+      final result = await Process.run(Platform.resolvedExecutable, [
+        if (Platform.packageConfig != null)
+          '--packages=${Platform.packageConfig}',
+        p.join(packageDir, 'bin', 'api_summary.dart'),
+        '-p',
+        packageDir,
+      ], workingDirectory: packageDir);
+
+      expect(
+        result.exitCode,
+        equals(0),
+        reason: 'CLI run failed with stderr:\n${result.stderr}',
+      );
+
+      final goldenFile = File(p.join(packageDir, 'api.txt'));
+      final expectedOutput = LineSplitter.split(
+        goldenFile.readAsStringSync(),
+      ).join('\n');
+      final actualOutput = LineSplitter.split(
+        result.stdout.toString(),
+      ).join('\n');
+
+      expect(actualOutput, equals(expectedOutput));
+    },
+  );
+}
diff --git a/pkgs/api_summary/test/extensions_test.dart b/pkgs/api_summary/test/extensions_test.dart
new file mode 100644
index 0000000..651673b
--- /dev/null
+++ b/pkgs/api_summary/test/extensions_test.dart
@@ -0,0 +1,128 @@
+// Copyright (c) 2026, 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: non_constant_identifier_names
+
+import 'package:api_summary/src/extensions.dart';
+import 'package:test/test.dart';
+import 'package:test_reflective_loader/test_reflective_loader.dart';
+
+import 'test_utils.dart';
+
+void main() {
+  defineReflectiveSuite(() {
+    defineReflectiveTests(ExtensionsTest);
+  });
+}
+
+@reflectiveTest
+class ExtensionsTest extends ApiSummaryTest {
+  Future<void> test_element_apiName_classMember() async {
+    final class_ = (await analyzeLibrary('''
+class C {
+  void method() {}
+  int get getter => 0;
+  set setter(int value) {}
+  int field = 0;
+  static const constant = 0;
+}
+''')).getClass('C')!;
+    expect(class_.getMethod('method')!.apiName, 'method');
+    expect(class_.getGetter('getter')!.apiName, 'getter');
+    expect(class_.getSetter('setter')!.apiName, 'setter=');
+    expect(class_.getField('field')!.apiName, 'field');
+    expect(class_.getField('constant')!.apiName, 'constant');
+  }
+
+  Future<void> test_element_apiName_topLevel() async {
+    final lib = await analyzeLibrary('''
+void function() {}
+int get getter => 0;
+set setter(int value) {}
+int variable = 0;
+const constant = 0;
+class Class {}
+mixin Mixin {}
+enum Enum { e }
+extension Extension on int {}
+extension type ExtensionType(int i) {}
+''');
+    expect(lib.getTopLevelFunction('function')!.apiName, 'function');
+    expect(lib.getGetter('getter')!.apiName, 'getter');
+    expect(lib.getSetter('setter')!.apiName, 'setter=');
+    expect(lib.getTopLevelVariable('variable')!.apiName, 'variable');
+    expect(lib.getTopLevelVariable('constant')!.apiName, 'constant');
+    expect(lib.getClass('Class')!.apiName, 'Class');
+    expect(lib.getMixin('Mixin')!.apiName, 'Mixin');
+    expect(lib.getEnum('Enum')!.apiName, 'Enum');
+    expect(lib.getExtension('Extension')!.apiName, 'Extension');
+    expect(lib.getExtensionType('ExtensionType')!.apiName, 'ExtensionType');
+  }
+
+  Future<void> test_formalParameterElement_isDeprecated() async {
+    final f = (await analyzeLibrary(
+      'f({int? i, @deprecated int? j}) {}',
+    )).getTopLevelFunction('f')!;
+    expect(f.formalParameters[0].isDeprecated, isFalse);
+    expect(f.formalParameters[1].isDeprecated, isTrue);
+  }
+
+  void test_iterableIterable_separatedBy() {
+    expect(
+      [
+        ['a', 'b'],
+        ['c', 'd'],
+      ].separatedBy(),
+      ['', 'a', 'b', ', ', 'c', 'd', ''],
+    );
+    expect(
+      [
+        ['a', 'b'],
+        ['c', 'd'],
+      ].separatedBy(prefix: '[', separator: '|', suffix: ']'),
+      ['[', 'a', 'b', '|', 'c', 'd', ']'],
+    );
+    expect(
+      <Iterable<Object?>>[].separatedBy(
+        prefix: '[',
+        separator: '|',
+        suffix: ']',
+      ),
+      ['[', ']'],
+    );
+  }
+
+  void test_string_isPublic() {
+    expect('_'.isPublic, isFalse);
+    expect('foo'.isPublic, isTrue);
+    expect('_foo'.isPublic, isFalse);
+  }
+
+  void test_uri_isIn() {
+    expect(Uri.parse('package:foo/bar.dart').isIn('foo'), isTrue);
+    expect(Uri.parse('package:foo/bar.dart').isIn('bar.dart'), isFalse);
+    expect(Uri.parse('dart:core').isIn('foo'), isFalse);
+    expect(Uri.parse('dart:core').isIn('dart'), isFalse);
+    expect(Uri.parse('dart:core').isIn('core'), isFalse);
+  }
+
+  void test_uri_isInPublicLibOf() {
+    expect(Uri.parse('package:foo/bar.dart').isInPublicLibOf('foo'), isTrue);
+    expect(
+      Uri.parse('package:foo/bar.dart').isInPublicLibOf('bar.dart'),
+      isFalse,
+    );
+    expect(
+      Uri.parse('package:foo/src/bar.dart').isInPublicLibOf('foo'),
+      isFalse,
+    );
+    expect(
+      Uri.parse('package:foo/src/bar.dart').isInPublicLibOf('src'),
+      isFalse,
+    );
+    expect(Uri.parse('dart:core').isInPublicLibOf('foo'), isFalse);
+    expect(Uri.parse('dart:core').isInPublicLibOf('dart'), isFalse);
+    expect(Uri.parse('dart:core').isInPublicLibOf('core'), isFalse);
+  }
+}
diff --git a/pkgs/api_summary/test/member_sorting_test.dart b/pkgs/api_summary/test/member_sorting_test.dart
new file mode 100644
index 0000000..fe1d8f7
--- /dev/null
+++ b/pkgs/api_summary/test/member_sorting_test.dart
@@ -0,0 +1,207 @@
+// Copyright (c) 2026, 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: non_constant_identifier_names
+
+import 'package:analyzer/dart/element/element.dart';
+import 'package:api_summary/src/extensions.dart';
+import 'package:api_summary/src/member_sorting.dart';
+import 'package:collection/collection.dart';
+import 'package:test/test.dart';
+import 'package:test_reflective_loader/test_reflective_loader.dart';
+
+import 'test_utils.dart';
+
+void main() {
+  defineReflectiveSuite(() {
+    defineReflectiveTests(MemberTest);
+  });
+}
+
+@reflectiveTest
+class MemberTest extends ApiSummaryTest {
+  Future<void> test_sortOrder_member_categoryBeforeName() async {
+    _checkSorting(
+      elements: (await analyzeLibrary('''
+class C {
+  C.a1();
+  get a2 => 0;
+  a3() {}
+  C.z1();
+  get z2 => 0;
+  z3() {}
+  }
+''')).getClass('C')!.childrenExcludingPropertyInducingElements,
+      expectedOrder: ['a1', 'z1', 'a2', 'z2', 'a3', 'z3'],
+    );
+  }
+
+  Future<void> test_sortOrder_member_gettersAndSettersTogether() async {
+    // Note that it's not good enough for the implementation to sort by apiName,
+    // because `9` is ASCII 0x39 and `=` is ASCII 0x3d, so sorting by apiName
+    // would put `a9` and `a9=` between `a` and `a=`.
+    _checkSorting(
+      elements: (await analyzeLibrary('''
+class C {
+  get a => 0;
+  get a9 => 0;
+  get aA => 0;
+  set a(value) {}
+  set a9(value) {}
+  set aA(value) {}
+}
+''')).getClass('C')!.childrenExcludingPropertyInducingElements,
+      expectedOrder: ['new', 'a', 'a=', 'a9', 'a9=', 'aA', 'aA='],
+    );
+  }
+
+  Future<void> test_sortOrder_member_staticBeforeInstance() async {
+    _checkSorting(
+      elements: (await analyzeLibrary('''
+class C {
+  C.a1();
+  C.z1();
+  get a2 => 0;
+  get z2 => 0;
+  static get a3 => 0;
+  static get z3 => 0;
+  a4() {}
+  z4() {}
+  static a5() {}
+  static z5() {}
+}
+''')).getClass('C')!.childrenExcludingPropertyInducingElements,
+      expectedOrder: [
+        'a3',
+        'z3',
+        'a5',
+        'z5',
+        'a1',
+        'z1',
+        'a2',
+        'z2',
+        'a4',
+        'z4',
+      ],
+    );
+  }
+
+  Future<void> test_sortOrder_member_unnamedConstructorBeforeOthers() async {
+    // Note that it's not good enough for the implementation to sort by apiName,
+    // because that would put `new` after `A`.
+    _checkSorting(
+      elements: (await analyzeLibrary('''
+class C {
+  C();
+  C.A();
+  C.z();
+}
+''')).getClass('C')!.childrenExcludingPropertyInducingElements,
+      expectedOrder: ['new', 'A', 'z'],
+    );
+  }
+
+  Future<void> test_sortOrder_topLevel_categoryBeforeName() async {
+    _checkSorting(
+      elements: (await analyzeLibrary('''
+get a1 => 0;
+a2() {}
+class a3 {}
+extension a4 on int {}
+typedef a5 = int;
+get z1 => 0;
+z2() {}
+class z3 {}
+extension z4 on int {}
+typedef z5 = int;
+''')).childrenExcludingPropertyInducingElements,
+      expectedOrder: [
+        'a1',
+        'z1',
+        'a2',
+        'z2',
+        'a3',
+        'z3',
+        'a4',
+        'z4',
+        'a5',
+        'z5',
+      ],
+    );
+  }
+
+  Future<void> test_sortOrder_topLevel_gettersAndSettersTogether() async {
+    // Note that it's not good enough for the implementation to sort by apiName,
+    // because `9` is ASCII 0x39 and `=` is ASCII 0x3d, so sorting by apiName
+    // would put `a9` and `a9=` between `a` and `a=`.
+    _checkSorting(
+      elements: (await analyzeLibrary('''
+get a => 0;
+get a9 => 0;
+get aA => 0;
+set a(value) {}
+set a9(value) {}
+set aA(value) {}
+''')).childrenExcludingPropertyInducingElements,
+      expectedOrder: ['a', 'a=', 'a9', 'a9=', 'aA', 'aA='],
+    );
+  }
+
+  Future<void> test_sortOrder_topLevel_interfaceTypesTogether() async {
+    _checkSorting(
+      elements: (await analyzeLibrary('''
+class A1 {}
+class Z1 {}
+mixin A2 {}
+mixin Z2 {}
+enum A3 { v }
+enum Z3 { v }
+extension type A4(int i) {}
+extension type Z4(int i) {}
+''')).childrenExcludingPropertyInducingElements,
+      expectedOrder: ['A1', 'A2', 'A3', 'A4', 'Z1', 'Z2', 'Z3', 'Z4'],
+    );
+  }
+
+  Future<void> test_sortOrder_topLevel_oldAndNewTypedefsTogether() async {
+    _checkSorting(
+      elements: (await analyzeLibrary('''
+typedef void A1();
+typedef void Z1();
+typedef A2 = void Function();
+typedef Z2 = void Function();
+typedef A3 = int;
+typedef Z3 = int;
+''')).childrenExcludingPropertyInducingElements,
+      expectedOrder: ['A1', 'A2', 'A3', 'Z1', 'Z2', 'Z3'],
+    );
+  }
+
+  void _checkSorting({
+    required List<Element> elements,
+    required List<String> expectedOrder,
+  }) {
+    expect(
+      elements.sortedBy(MemberSortKey.new).map((e) => e.apiName).toList(),
+      expectedOrder,
+    );
+    expect(
+      elements.reversed
+          .sortedBy(MemberSortKey.new)
+          .map((e) => e.apiName)
+          .toList(),
+      expectedOrder,
+    );
+  }
+}
+
+extension on Element {
+  /// All children of `this` excluding [PropertyInducingElement]s.
+  ///
+  /// This is used for testing the sort order of class members, since the API
+  /// summary only considers getters and setters; it ignores the fields and top
+  /// level variables that induce them.
+  List<Element> get childrenExcludingPropertyInducingElements =>
+      children.whereNot((e) => e is PropertyInducingElement).toList();
+}
diff --git a/pkgs/api_summary/test/node_test.dart b/pkgs/api_summary/test/node_test.dart
new file mode 100644
index 0000000..a4e208a
--- /dev/null
+++ b/pkgs/api_summary/test/node_test.dart
@@ -0,0 +1,97 @@
+// Copyright (c) 2026, 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: non_constant_identifier_names
+
+import 'package:api_summary/src/node.dart';
+import 'package:test/test.dart';
+import 'package:test_reflective_loader/test_reflective_loader.dart';
+
+void main() {
+  defineReflectiveSuite(() {
+    defineReflectiveTests(NodeTest);
+  });
+}
+
+@reflectiveTest
+class NodeTest {
+  void test_printNodes_indentChildNodes() {
+    final buf = StringBuffer();
+    printNodes(buf, [
+      (
+        1,
+        _SimpleNode('one', [
+          (2, _SimpleNode('two')),
+          (3, _SimpleNode('three')),
+        ]),
+      ),
+      (
+        4,
+        _SimpleNode('four', [
+          (5, _SimpleNode('five')),
+          (6, _SimpleNode('six')),
+        ]),
+      ),
+    ]);
+    expect(buf.toString(), '''
+one
+  two
+  three
+four
+  five
+  six
+''');
+  }
+
+  void test_printNodes_joinTextStrings() {
+    final buf = StringBuffer();
+    printNodes(buf, [
+      (1, Node<num>()..text.addAll(['x', 0])),
+    ]);
+    expect(buf.toString(), '''
+x0
+''');
+  }
+
+  void test_printNodes_sortChildNodesByKey() {
+    final buf = StringBuffer();
+    printNodes(buf, [
+      (
+        0,
+        _SimpleNode('zero', [
+          (2, _SimpleNode('two')),
+          (1, _SimpleNode('one')),
+          (3, _SimpleNode('three')),
+        ]),
+      ),
+    ]);
+    expect(buf.toString(), '''
+zero
+  one
+  two
+  three
+''');
+  }
+
+  void test_printNodes_sortedByKey() {
+    final buf = StringBuffer();
+    printNodes(buf, [
+      (2, _SimpleNode('two')),
+      (1, _SimpleNode('one')),
+      (3, _SimpleNode('three')),
+    ]);
+    expect(buf.toString(), '''
+one
+two
+three
+''');
+  }
+}
+
+class _SimpleNode extends Node<num> {
+  _SimpleNode(String text, [List<(num, Node<num>)> childNodes = const []]) {
+    this.text.add(text);
+    this.childNodes.addAll(childNodes);
+  }
+}
diff --git a/pkgs/api_summary/test/test_utils.dart b/pkgs/api_summary/test/test_utils.dart
new file mode 100644
index 0000000..2376ed1
--- /dev/null
+++ b/pkgs/api_summary/test/test_utils.dart
@@ -0,0 +1,26 @@
+// Copyright (c) 2026, 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/element/element.dart';
+import 'package:analyzer/diagnostic/diagnostic.dart';
+// TODO(paulberry): move pub_package_resolution.dart out of src
+import 'package:analyzer_testing/src/analysis_rule/pub_package_resolution.dart';
+import 'package:test/test.dart';
+
+class ApiSummaryTest extends PubPackageResolutionTest {
+  Future<LibraryElement> analyzeLibrary(
+    String content, {
+    String pathWithinLib = 'test.dart',
+  }) async {
+    final file = newFile('$testPackageLibPath/$pathWithinLib', content);
+    final resolvedUnitResult = await resolveFile(file.path);
+    expect(
+      resolvedUnitResult.diagnostics.where(
+        (diagnostic) => diagnostic.severity == Severity.error,
+      ),
+      isEmpty,
+    );
+    return resolvedUnitResult.libraryElement;
+  }
+}
diff --git a/pkgs/api_summary/test/unique_namer_test.dart b/pkgs/api_summary/test/unique_namer_test.dart
new file mode 100644
index 0000000..cb95b28
--- /dev/null
+++ b/pkgs/api_summary/test/unique_namer_test.dart
@@ -0,0 +1,82 @@
+// Copyright (c) 2026, 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: non_constant_identifier_names
+
+import 'package:api_summary/src/unique_namer.dart';
+import 'package:test/test.dart';
+import 'package:test_reflective_loader/test_reflective_loader.dart';
+
+import 'test_utils.dart';
+
+void main() {
+  defineReflectiveSuite(() {
+    defineReflectiveTests(UniqueNamerTest);
+  });
+}
+
+@reflectiveTest
+class UniqueNamerTest extends ApiSummaryTest {
+  Future<void> test_collidingNamesAreDisambiguated() async {
+    final f1 = (await analyzeLibrary(
+      pathWithinLib: 'file1.dart',
+      'f() {}',
+    )).getTopLevelFunction('f')!;
+    final f2 = (await analyzeLibrary(
+      pathWithinLib: 'file2.dart',
+      'f() {}',
+    )).getTopLevelFunction('f')!;
+    final uniqueNamer = UniqueNamer();
+    final f1Name = uniqueNamer.name(f1);
+    final f2Name = uniqueNamer.name(f2);
+    expect(f1Name.toString(), 'f@1');
+    expect(f2Name.toString(), 'f@2');
+  }
+
+  Future<void> test_name_returnsSameNameOnSuccessiveCalls() async {
+    final f = (await analyzeLibrary('f() {}')).getTopLevelFunction('f')!;
+    final uniqueNamer = UniqueNamer();
+    final name1 = uniqueNamer.name(f);
+    final name2 = uniqueNamer.name(f);
+    expect(name1, same(name2));
+  }
+
+  Future<void> test_nonCollidingNamesAreNotDisambiguated() async {
+    final f = (await analyzeLibrary(
+      pathWithinLib: 'file1.dart',
+      'f() {}',
+    )).getTopLevelFunction('f')!;
+    final g = (await analyzeLibrary(
+      pathWithinLib: 'file2.dart',
+      'g() {}',
+    )).getTopLevelFunction('g')!;
+    final uniqueNamer = UniqueNamer();
+    final fName = uniqueNamer.name(f);
+    final gName = uniqueNamer.name(g);
+    expect(fName.toString(), 'f');
+    expect(gName.toString(), 'g');
+  }
+
+  Future<void> test_three_collisions() async {
+    final f1 = (await analyzeLibrary(
+      pathWithinLib: 'file1.dart',
+      'f() {}',
+    )).getTopLevelFunction('f')!;
+    final f2 = (await analyzeLibrary(
+      pathWithinLib: 'file2.dart',
+      'f() {}',
+    )).getTopLevelFunction('f')!;
+    final f3 = (await analyzeLibrary(
+      pathWithinLib: 'file3.dart',
+      'f() {}',
+    )).getTopLevelFunction('f')!;
+    final uniqueNamer = UniqueNamer();
+    final f1Name = uniqueNamer.name(f1);
+    final f2Name = uniqueNamer.name(f2);
+    final f3Name = uniqueNamer.name(f3);
+    expect(f1Name.toString(), 'f@1');
+    expect(f2Name.toString(), 'f@2');
+    expect(f3Name.toString(), 'f@3');
+  }
+}
diff --git a/pkgs/api_summary/test/uri_sorting_test.dart b/pkgs/api_summary/test/uri_sorting_test.dart
new file mode 100644
index 0000000..3d7a541
--- /dev/null
+++ b/pkgs/api_summary/test/uri_sorting_test.dart
@@ -0,0 +1,56 @@
+// Copyright (c) 2026, 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: non_constant_identifier_names
+
+import 'package:api_summary/src/uri_sorting.dart';
+import 'package:collection/collection.dart';
+import 'package:test/test.dart';
+import 'package:test_reflective_loader/test_reflective_loader.dart';
+
+void main() {
+  defineReflectiveSuite(() {
+    defineReflectiveTests(UriTest);
+  });
+}
+
+@reflectiveTest
+class UriTest {
+  void test_sortOrder_inOrOutOfPackageBeforeName() {
+    _checkSorting(
+      uris: [
+        Uri.parse('package:a/a.dart'),
+        Uri.parse('package:b/b.dart'),
+        Uri.parse('package:c/c.dart'),
+      ],
+      expectedOrder: [
+        'package:b/b.dart',
+        'package:a/a.dart',
+        'package:c/c.dart',
+      ],
+      packageName: 'b',
+    );
+  }
+
+  void _checkSorting({
+    required List<Uri> uris,
+    required List<String> expectedOrder,
+    required String packageName,
+  }) {
+    expect(
+      uris
+          .sortedBy((e) => UriSortKey(e, packageName))
+          .map((e) => e.toString())
+          .toList(),
+      expectedOrder,
+    );
+    expect(
+      uris.reversed
+          .sortedBy((e) => UriSortKey(e, packageName))
+          .map((e) => e.toString())
+          .toList(),
+      expectedOrder,
+    );
+  }
+}