| // 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')) { | |
| final pathSegments = entity.path.split(Platform.pathSeparator); | |
| if (pathSegments.contains('.dart_tool') || | |
| pathSegments.contains('ephemeral')) { | |
| // Can contain generated or symlinked pubspecs. | |
| 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/pub_formats/tool/generate.dart', | |
| 'pkgs/record_use/tool/generate_syntax.dart', | |
| ]; | |
| const snippetTargets = [ | |
| 'pkgs/hooks', | |
| 'pkgs/code_assets', | |
| 'pkgs/data_assets', | |
| 'pkgs/jni_util', | |
| 'pkgs/web_assets', | |
| 'pkgs/record_use', | |
| 'pkgs/swift2objc', | |
| ]; | |
| final fix = argResults['fix'] as bool; | |
| await _runMaybeParallel([ | |
| for (final generator in generators) | |
| () => _runProcess('dart', [ | |
| generator, | |
| if (!fix) '--set-exit-if-changed', | |
| ]), | |
| () => _runProcess('dart', [ | |
| 'run', | |
| 'snippet_tool', | |
| if (!fix) '--set-exit-if-changed', | |
| ...snippetTargets, | |
| ]), | |
| ], 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'); | |
| if (coverageTask.shouldRun(argResults)) { | |
| final coverageDir = Directory.fromUri(repositoryRoot.resolve('coverage')); | |
| if (coverageDir.existsSync()) { | |
| coverageDir.deleteSync(recursive: true); | |
| } | |
| } | |
| 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/', | |
| // TODO(https://github.com/dart-lang/sdk/issues/63372): Run this on ci | |
| // after dev/stable support it. | |
| // 'pkgs/hooks_runner/test_data/treeshaking_dylib_record_use/', | |
| 'pkgs/hooks/example/build/download_asset/', | |
| 'pkgs/hooks/example/build/native_add_app/', | |
| 'pkgs/hooks/example/build/native_dynamic_linking/', | |
| 'pkgs/hooks/example/build/prebuilt_assets_example/', | |
| 'pkgs/hooks/example/build/system_library/', | |
| 'pkgs/hooks/example/build/use_dart_api/', | |
| ]; | |
| // Run sequentially because `dart test` in a pub workspace copies to the | |
| // shared `<workspace_root>/.dart_tool/native_assets.yaml`. | |
| for (final exampleWithTest in examplesWithTest) { | |
| await _runProcess( | |
| workingDirectory: repositoryRoot.resolve(exampleWithTest), | |
| 'dart', | |
| ['test'], | |
| ); | |
| } | |
| 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'], | |
| ); | |
| 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', | |
| '--check-ignore', | |
| '--ignore-files=**/*.g.dart', | |
| '--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, | |
| ]); | |
| } | |
| } | |
| /// Checks for missing, under-promoted, over-promoted, and unused dependencies. | |
| class DependencyValidatorTask extends Task { | |
| const DependencyValidatorTask() | |
| : super( | |
| name: 'dependency-validator', | |
| helpMessage: 'Run `dependency_validator` on the packages.', | |
| ); | |
| @override | |
| Future<void> run({ | |
| required List<String> packages, | |
| required ArgResults argResults, | |
| }) async { | |
| await _runMaybeParallel([ | |
| for (final package in packages) | |
| () => _runProcess('dart', [ | |
| 'run', | |
| 'dependency_validator', | |
| '-C', | |
| package, | |
| ]), | |
| ], argResults); | |
| } | |
| } | |
| 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(); | |
| const dependencyValidatorTask = DependencyValidatorTask(); | |
| // The order of tasks is intentional. | |
| final tasks = [ | |
| pubTask, | |
| generateTask, | |
| licenseTask, | |
| analyzeTask, | |
| formatTask, | |
| dependencyValidatorTask, | |
| 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); | |
| } | |
| } |