blob: 56410d6a89c4760e5ea170270f8269d424e6f60a [file] [edit]
// Copyright (c) 2025, 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:ffi';
import 'dart:io';
import 'package:args/args.dart';
import 'package:path/path.dart' as p;
import 'package:yaml/yaml.dart';
void main(List<String> arguments) async {
final parser = makeArgParser();
final argResults = parser.parse(arguments);
final packages = loadPackagesFromPubspec();
if (argResults['help'] as bool) {
print('A command-line tool for running CI checks on the CI or locally.');
print('');
print('Applies to the following packages:');
for (final package in packages) {
print(' - $package');
}
print('');
print('Usage:');
print(parser.usage);
return;
}
final tasksToRun = tasks.where((task) => task.shouldRun(argResults)).toList();
if (tasksToRun.isEmpty) {
print('No tasks specified. Please specify at least one task to run.');
exit(1);
}
for (final task in tasksToRun) {
await task.run(packages: packages, argResults: argResults);
}
}
ArgParser makeArgParser() {
final parser = ArgParser()
..addFlag(
'help',
abbr: 'h',
negatable: false,
help: 'Prints this help message.',
)
..addFlag(
'all',
negatable: false,
help: 'Enable all tasks. Overridden by --no-<task> flags.',
)
..addFlag(
'fast',
negatable: false,
help: 'Skip slow integration tests and apitool.',
)
..addFlag(
'fix',
negatable: false,
help:
'Apply auto-fixes (e.g., dart fix, dart format) instead of just '
'checking.',
);
for (final task in tasks) {
parser.addFlag(task.name, help: task.helpMessage);
}
return parser;
}
/// Represents a single CI task that can be run.
///
/// Each task is a concrete subclass of [Task], providing metadata about the
/// task's name, its default state, and the help message for its corresponding
/// command-line flag.
///
/// The main execution loop iterates through a list of [Task] instances. For
/// each instance, it uses [shouldRun] to determine if the task should be
/// executed based on the command-line flags, and if so, calls the [run] method.
abstract class Task {
/// The name of the task, used for the command-line flag.
///
/// For example, a name of 'analyze' corresponds to the `--[no-]analyze` flag.
final String name;
/// The base help message for the task's command-line flag.
final String helpMessage;
const Task({required this.name, required this.helpMessage});
bool shouldRun(ArgResults argResults) {
final useAll = argResults['all'] as bool;
if (argResults.wasParsed(name)) {
return argResults[name] as bool;
}
if (useAll) {
return true;
}
return false;
}
Future<void> run({
required List<String> packages,
required ArgResults argResults,
});
}
/// Ensures all packages are included in the pub workspace or a reason is
/// provided why they cannot be part of the workspace.
class WorkspaceTask extends Task {
const WorkspaceTask()
: super(
name: 'workspace',
helpMessage:
'Check that all packages are included in the pub workspace.',
);
@override
Future<void> run({
required List<String> packages,
required ArgResults argResults,
}) async {
final packagesInRepository = _packagesInRepository();
final packagesInWorkspacePubspec = _packagesInWorkspacePubspec();
final error = <String>[];
if (packagesInWorkspacePubspec.missingReason.isNotEmpty) {
error
..add(
'The following packages are commented out in the workspace '
'pubspec, but no reason is given why they cannot be part of the '
'workspace:',
)
..addAll(
packagesInWorkspacePubspec.missingReason.map(
(package) => ' - $package',
),
)
..add(
'Please add a trailing comment to their entry providing a reason '
'for their exclusion from the workspace.',
)
..add('');
}
final notInRepository = packagesInWorkspacePubspec.packages.difference(
packagesInRepository,
);
if (notInRepository.isNotEmpty) {
error
..add(
'The following packages are listed in the workspace pubspec, but '
'do not exist in the repository:',
)
..addAll(notInRepository.map((package) => ' - $package'))
..add('Please remove them from the workspace pubspec.')
..add('');
}
final notInWorkspacePubspec = packagesInRepository.difference(
packagesInWorkspacePubspec.packages,
);
if (notInWorkspacePubspec.isNotEmpty) {
error
..add(
'The following packages exist in the repository, but are not part '
'of the workspace:',
)
..addAll(notInWorkspacePubspec.map((package) => ' - $package'))
..add(
'Please add them to the workspace. If that is not possible, add a '
'commented out entry to the root pubspec and provide a reason why '
'they cannot be part of the workspace as a trailing comment.',
)
..add('');
}
if (error.isNotEmpty) {
print(error.join('\n'));
exit(1);
}
}
Set<String> _packagesInRepository() {
final packages = <String>{};
final rootDir = Directory.fromUri(repositoryRoot.resolve('pkgs'));
for (final entity in rootDir.listSync(recursive: true)) {
if (entity is File && entity.path.endsWith('pubspec.yaml')) {
if (entity.path.split(Platform.pathSeparator).contains('.dart_tool')) {
continue;
}
packages.add(
Uri.file(
p.relative(entity.parent.path, from: repositoryRoot.toFilePath()),
).toString(),
);
}
}
return packages;
}
({Set<String> packages, List<String> missingReason})
_packagesInWorkspacePubspec() {
final pubspecLines = File.fromUri(
repositoryRoot.resolve('pubspec.yaml'),
).readAsStringSync().split('\n');
final workspaceEntries = pubspecLines
.skipWhile((line) => !line.trim().startsWith('workspace:'))
.skip(1)
.takeWhile(
(line) =>
line.trim().startsWith('- ') || line.trim().startsWith('# - '),
);
final packages = <String>{};
final packagesWithMissingReason = <String>[];
// Regex breakdown:
// ^\s* : Start of line and any leading whitespace
// (#\s*)? : Optional leading '#' followed by optional whitespace (Group 1)
// -\s+ : The YAML list dash '-' and at least one space
// ([^\s#]+) : The actual path - any characters that aren't space or '#' (Group 2)
// (\s*#.*)? : Optional trailing '#' and everything after it (Group 3)
final regex = RegExp(r'^\s*(#\s*)?-\s+([^\s#]+)(\s*#.*)?');
for (final entry in workspaceEntries) {
final match = regex.firstMatch(entry);
if (match != null) {
final hasLeadingHash = match.group(1) != null;
final path = match.group(2);
final trailingComment = match.group(3);
if (hasLeadingHash) {
if (trailingComment == null || trailingComment.trim().length < 6) {
packagesWithMissingReason.add(path!);
}
}
packages.add(path!);
}
}
return (packages: packages, missingReason: packagesWithMissingReason);
}
}
/// Fetches dependencies using `dart pub get`.
///
/// This is a prerequisite for most other tasks.
class PubTask extends Task {
const PubTask()
: super(
name: 'pub',
helpMessage:
'Run `dart pub get` on the root and non-workspace packages.\n'
'Run `dart pub global activate coverage` and `dart_apitool`.',
);
@override
Future<void> run({
required List<String> packages,
required ArgResults argResults,
}) async {
const paths = [
'.',
'pkgs/hooks_runner/test_data/native_add_version_skew/',
'pkgs/hooks_runner/test_data/native_add_version_skew_2/',
];
await _runMaybeParallel([
for (final path in paths)
() => _runProcess('dart', ['pub', 'get', '--directory', path]),
], argResults);
}
}
/// Packages that have slow tests.
///
/// https://github.com/dart-lang/native/issues/90#issuecomment-3879193057
const slowTestPackages = [
'pkgs/hooks_runner',
'pkgs/native_toolchain_c',
];
/// Runs `dart analyze` to find static analysis issues.
class AnalyzeTask extends Task {
const AnalyzeTask()
: super(
name: 'analyze',
helpMessage: 'Run `dart analyze` on the packages.',
);
@override
Future<void> run({
required List<String> packages,
required ArgResults argResults,
}) async {
final paths = [
...packages,
'tool',
'pubspec.yaml',
];
if (argResults['fix'] as bool) {
await _runMaybeParallel([
for (final path in paths)
() => _runProcess('dart', ['fix', '--apply', path]),
], argResults);
}
await _runProcess('dart', ['analyze', '--fatal-infos', ...paths]);
}
}
/// Checks for code formatting issues with `dart format`.
class FormatTask extends Task {
const FormatTask()
: super(name: 'format', helpMessage: 'Run `dart format` on the packages.');
@override
Future<void> run({
required List<String> packages,
required ArgResults argResults,
}) async {
final fix = argResults['fix'] as bool;
await _runProcess('dart', [
'format',
if (!fix) ...['--output=none', '--set-exit-if-changed'],
...packages,
'tool',
]);
}
}
/// Runs various code generation scripts.
///
/// This is used to keep generated files in sync with their sources.
class GenerateTask extends Task {
const GenerateTask()
: super(name: 'generate', helpMessage: 'Run code generation scripts.');
@override
Future<void> run({
required List<String> packages,
required ArgResults argResults,
}) async {
const generators = [
'pkgs/code_assets/example/host_name/tool/ffigen.dart',
'pkgs/code_assets/example/mini_audio/tool/ffigen.dart',
'pkgs/code_assets/example/sqlite/tool/ffigen.dart',
'pkgs/code_assets/example/sqlite_no_link/tool/ffigen.dart',
'pkgs/code_assets/example/sqlite_prebuilt/tool/ffigen.dart',
'pkgs/code_assets/example/stb_image/tool/ffigen.dart',
'pkgs/hooks_runner/test_data/manifest_generator.dart',
'pkgs/hooks/tool/generate_schemas.dart',
'pkgs/hooks/tool/generate_syntax.dart',
'pkgs/hooks/tool/normalize.dart',
'pkgs/hooks/tool/update_snippets.dart',
'pkgs/pub_formats/tool/generate.dart',
'pkgs/record_use/tool/generate_syntax.dart',
];
final fix = argResults['fix'] as bool;
await _runMaybeParallel([
for (final generator in generators)
() => _runProcess('dart', [
generator,
if (!fix) '--set-exit-if-changed',
]),
], argResults);
}
}
/// Runs the main test suite for all packages.
class TestTask extends Task {
const TestTask()
: super(
name: 'test',
helpMessage:
'Run `dart test` on the packages.\n'
'Implied by --coverage.',
);
@override
bool shouldRun(ArgResults argResults) {
return super.shouldRun(argResults) || coverageTask.shouldRun(argResults);
}
@override
Future<void> run({
required List<String> packages,
required ArgResults argResults,
}) async {
if (argResults['fast'] as bool) {
packages = packages
.where((p) => !slowTestPackages.any((slow) => p.contains(slow)))
.toList();
}
final testUris = getUriInPackage(packages, 'test');
await _runProcess('dart', [
'test',
if (coverageTask.shouldRun(argResults)) '--coverage=./coverage',
if (Platform.environment['GITHUB_ACTIONS'] != null) '--reporter=github',
...testUris,
]);
}
}
/// Runs tests and executables for all examples.
///
/// Ensures that the examples are working and up-to-date.
class ExampleTask extends Task {
const ExampleTask()
: super(
name: 'example',
helpMessage: 'Run tests and executables for examples.',
);
@override
Future<void> run({
required List<String> packages,
required ArgResults argResults,
}) async {
const examplesWithTest = [
'pkgs/code_assets/example/host_name/',
'pkgs/code_assets/example/mini_audio/',
'pkgs/code_assets/example/sqlite_no_link/',
'pkgs/code_assets/example/sqlite_prebuilt/',
'pkgs/code_assets/example/sqlite/',
'pkgs/code_assets/example/stb_image/',
'pkgs/hooks/example/build/download_asset/',
'pkgs/hooks/example/build/native_add_app/',
'pkgs/hooks/example/build/native_dynamic_linking/',
'pkgs/hooks/example/build/system_library/',
'pkgs/hooks/example/build/use_dart_api/',
];
await _runMaybeParallel([
for (final exampleWithTest in examplesWithTest)
() => _runProcess(
workingDirectory: repositoryRoot.resolve(exampleWithTest),
'dart',
['test'],
),
], argResults);
await _runProcess(
workingDirectory: repositoryRoot.resolve(
'pkgs/hooks/example/build/native_add_app/',
),
'dart',
['run'],
);
await _runProcess(
workingDirectory: repositoryRoot.resolve(
'pkgs/hooks/example/build/native_add_app/',
),
'dart',
['build', 'cli', 'bin/native_add_app.dart'],
);
await _runProcess(
repositoryRoot
.resolve(
'pkgs/hooks/example/build/native_add_app/build/cli/${Abi.current()}/bundle/bin/native_add_app${Platform.isWindows ? '.exe' : ''}',
)
.toFilePath(),
[],
);
}
}
/// Generates test coverage reports.
///
/// Depends on `pub` being run to activate the `coverage` package.
class CoverageTask extends Task {
const CoverageTask()
: super(
name: 'coverage',
helpMessage:
'Collect coverage information on the packages.\n'
'Implies --test.',
);
@override
Future<void> run({
required List<String> packages,
required ArgResults argResults,
}) async {
if (pubTask.shouldRun(argResults)) {
await _runProcess('dart', ['pub', 'global', 'activate', 'coverage']);
}
// Don't rerun the tests here. Instead, rely on the TestTask producing
// coverage information, and only format here.
final libUris = getUriInPackage(packages, 'lib');
await _runProcess('dart', [
'pub',
'global',
'run',
'coverage:format_coverage',
'--packages=.dart_tool/package_config.json',
for (final libUri in libUris) '--report-on=$libUri',
'--lcov',
'-o',
'./coverage/lcov.info',
'-i',
'./coverage/pkgs/',
]);
}
}
/// Checks for leaked symbols in the public API using `dart_apitool`.
class ApiToolTask extends Task {
const ApiToolTask()
: super(
name: 'apitool',
helpMessage: 'Run `dart_apitool` to check for leaked symbols.',
);
@override
bool shouldRun(ArgResults argResults) {
if (argResults['fast'] as bool && !argResults.wasParsed(name)) {
return false;
}
return super.shouldRun(argResults);
}
@override
Future<void> run({
required List<String> packages,
required ArgResults argResults,
}) async {
if (pubTask.shouldRun(argResults)) {
await _runProcess('dart', [
'pub',
'global',
'activate',
'dart_apitool',
'^0.23.1',
]);
}
await _runMaybeParallel([
for (final package in packages)
() async {
final outputFileName = '${package.replaceAll('/', '_')}_api.json';
await _runProcess('dart', [
'pub',
'global',
'run',
'dart_apitool:main',
'extract',
'--input',
package,
'--set-exit-on-missing-export',
'--output',
outputFileName,
]);
// Clean up the temporary file.
final apiJson = File.fromUri(repositoryRoot.resolve(outputFileName));
if (apiJson.existsSync()) {
apiJson.deleteSync();
}
},
], argResults);
}
}
/// Checks for missing license headers.
class LicenseTask extends Task {
const LicenseTask()
: super(
name: 'license',
helpMessage: 'Check for missing license headers.',
);
@override
Future<void> run({
required List<String> packages,
required ArgResults argResults,
}) async {
final fix = argResults['fix'] as bool;
await _runProcess('dart', [
'tool/check_licenses.dart',
if (!fix) '--set-exit-if-changed',
...packages,
]);
}
}
const pubTask = PubTask();
const licenseTask = LicenseTask();
const analyzeTask = AnalyzeTask();
const formatTask = FormatTask();
const generateTask = GenerateTask();
const testTask = TestTask();
const exampleTask = ExampleTask();
const coverageTask = CoverageTask();
const apiToolTask = ApiToolTask();
const workspaceTask = WorkspaceTask();
// The order of tasks is intentional.
final tasks = [
pubTask,
generateTask,
licenseTask,
analyzeTask,
formatTask,
testTask,
exampleTask,
coverageTask,
apiToolTask,
workspaceTask,
];
final Uri repositoryRoot = Platform.script.resolve('../');
/// Load the root packages from the workspace. Omit nested test/example packages.
List<String> loadPackagesFromPubspec() {
final pubspecYaml = loadYaml(
File.fromUri(repositoryRoot.resolve('pubspec.yaml')).readAsStringSync(),
);
final workspace = ((pubspecYaml as Map)['workspace'] as List).cast<String>();
final packages = workspace
.where(
(package) =>
!package.contains('test_data') && !package.contains('example'),
)
.toList();
return packages;
}
List<String> getUriInPackage(List<String> packages, String subdir) {
final testUris = <String>[];
for (final package in packages) {
final packageTestDirectory = Directory.fromUri(
repositoryRoot.resolve(package /*might end without slash*/),
).uri.resolve('$subdir/');
if (Directory.fromUri(packageTestDirectory).existsSync()) {
final relativePath = packageTestDirectory.toFilePath().replaceAll(
repositoryRoot.toFilePath(),
'',
);
testUris.add(relativePath);
}
}
return testUris;
}
Future<void> _runMaybeParallel(
List<Future<void> Function()> tasks,
ArgResults argResults,
) async {
if (argResults['fast'] as bool) {
await Future.wait(tasks.map((task) => task()));
} else {
for (final task in tasks) {
await task();
}
}
}
Future<void> _runProcess(
String executable,
List<String> arguments, {
Uri? workingDirectory,
}) async {
var commandString = '$executable ${arguments.join(' ')}';
if (workingDirectory != null) {
commandString = 'cd ${workingDirectory.toFilePath()} && $commandString';
}
print('+$commandString');
final process = await Process.start(
executable,
arguments,
workingDirectory: workingDirectory?.toFilePath(),
// Support stderr and stdout directly to the output. This enables
// `dart test` and friends to replace the last line. Also those tools can
// detect properly if they run in an interactive terminal.
mode: ProcessStartMode.inheritStdio,
);
final exitCode = await process.exitCode;
if (exitCode != 0) {
print('+$commandString failed with exitCode $exitCode.');
exit(exitCode);
}
}