halkyon/tool/check.dart
Tizian 「ティツィアーン」 bf20134819
fix(tool): the check limited to changes sees uncommitted ones too
Melos' --diff compares commits alone (<commit>...HEAD), so a check with
--changed-since before committing tested nothing and passed. The check
now works out the packages touched itself, from git diff against the
commit and the files new to git, each file counted in its innermost
package, and hands them to Melos as --scope with --include-dependents;
a changed root pubspec, lock or lint rule means every package.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-22 18:11:05 +02:00

167 lines
5.4 KiB
Dart

// The checks every push has to pass: the packages the lock names, formatting,
// static analysis (infos are fatal too), and every package's tests. The
// pre-push hook (tool/pre-push) runs it, and so does CI on every push
// (.forgejo/workflows/ci.yaml).
//
// dart run tool/check.dart everything
// dart run tool/check.dart --changed-since=origin/main
// the tests of the packages changed since then (in
// the working tree too), and of what depends on them
//
// The tests run through Melos' scripts (`test:dart`, `test:flutter`, the root
// pubspec), Melos itself a dependency of the root.
// It checks the working tree, so uncommitted changes count as well.
import 'dart:convert';
import 'dart:io';
import 'package:args/args.dart';
import 'src/check_files.dart';
Future<void> main(List<String> arguments) async {
final options =
(ArgParser()
..addOption(
'changed-since',
help:
'Test only the packages changed since this commit, and those '
'depending on them.',
)
..addFlag('help', abbr: 'h', negatable: false))
.parse(arguments);
if (options.flag('help')) {
stdout.writeln(
'Usage: dart run tool/check.dart [--changed-since=<commit>]',
);
return;
}
final changedSince = options.option('changed-since');
Directory.current = (await _output('git', [
'rev-parse',
'--show-toplevel',
])).trim();
if ((await Process.run('git', ['diff', '--quiet', 'HEAD'])).exitCode != 0) {
stdout.writeln(
'Note: the working tree has uncommitted changes; they are checked too.',
);
}
// The packages the checked-out pubspecs and lock name, fetched first: after
// a merge or a switch of branch the workspace's resolution can still be the
// one from before, and the analysis then failed on packages it couldn't
// find (2026-10-13, 2026-10-17). The lock decides (--enforce-lockfile), so a
// check never changes it.
await _step('Packages', [
('flutter', ['pub', 'get', '--enforce-lockfile']),
], quiet: true);
final own = (await _output('git', [
'ls-files',
'*.dart',
])).split('\n').where(isOwnDartFile).toList();
await _step('Formatting', [
('dart', ['format', '--output=none', '--set-exit-if-changed', ...own]),
]);
await _step('Analysis', [
('dart', ['analyze', '--fatal-infos']),
]);
await _step('Tools', [
('dart', ['test', 'test/']),
]);
// The sync tests need `serverpod start` running (`requires-server`); run
// them by hand with `flutter test -t requires-server` in Learn's app.
final touched = changedSince == null
? null
: await _packagesChangedSince(changedSince);
if (touched != null && touched.isEmpty) {
stdout
..writeln('▸ Tests')
..writeln('No package changed since $changedSince.');
} else {
final filters = melosFilters(touched: touched);
await _step('Tests', [
('dart', ['run', 'melos', 'run', 'test:dart', ...filters]),
('dart', ['run', 'melos', 'run', 'test:flutter', ...filters]),
]);
}
stdout.writeln('✓ All checks passed.');
}
/// The packages changed since [commit], in the working tree too (committed
/// or not, and files new to git), or null for every package
/// ([packagesTouched]).
Future<List<String>?> _packagesChangedSince(String commit) async {
final paths = [
...(await _output('git', ['diff', '--name-only', commit])).split('\n'),
...(await _output('git', [
'ls-files',
'--others',
'--exclude-standard',
])).split('\n'),
].where((path) => path.isNotEmpty);
final root = Directory.current.path;
final packages = {
for (final package in jsonDecode(
await _output('dart', ['run', 'melos', 'list', '--json']),
) as List<Object?>)
if (package case {'name': final String name, 'location': final String at})
name: at.substring(root.length + 1),
};
return packagesTouched(paths, packages);
}
/// Runs [commands] one after another as the step [name], and ends the check
/// at the first that fails, with its exit code.
Future<void> _step(
String name,
List<(String, List<String>)> commands, {
bool quiet = false,
}) async {
stdout.writeln('▸ $name');
for (final (executable, arguments) in commands) {
final code = await _run(executable, arguments, quiet: quiet);
if (code != 0) {
stderr.writeln('✗ $name failed ($executable ${arguments.first}).');
exit(code);
}
}
}
/// Runs [executable] and returns its exit code; its output is passed on as
/// it comes, or if [quiet] only should it fail.
Future<int> _run(
String executable,
List<String> arguments, {
bool quiet = false,
}) async {
if (quiet) {
final result = await Process.run(executable, arguments);
if (result.exitCode != 0) {
stderr
..write(result.stdout)
..write(result.stderr);
}
return result.exitCode;
}
final process = await Process.start(
executable,
arguments,
mode: ProcessStartMode.inheritStdio,
);
return await process.exitCode;
}
/// What [executable] writes, failing the check if it fails.
Future<String> _output(String executable, List<String> arguments) async {
final result = await Process.run(executable, arguments);
if (result.exitCode != 0) {
stderr.write(result.stderr);
exit(result.exitCode);
}
return result.stdout as String;
}