Skip to content

avoid_exit_outside_entrypoint

v1.1.0WarningConfigurableCode Quality

Warns when dart:io’s exit() is called outside the program’s entrypoint. By default only bin/** is allowed.

exit() in domain code destroys testability outright: the test process disappears mid-assertion. There is nothing to catch, nothing to assert on, and a runner that reports nothing reads as a passing suite far more often than it should.

This rule is in the recommended preset and works with its default allow_in: ['bin/**']; configuration is only needed if your entrypoints live somewhere else.

lib/src/core/upload.dart
import 'dart:io';
Future<void> upload(File artifact, HttpClient http) async {
final request = await http.putUrl(Uri.https('example.com', '/artifact'));
final response = await request.close();
if (response.statusCode == 403) {
stderr.writeln('Permission denied');
exit(3);
}
}

A test of upload() cannot assert on that 403 branch — reaching it kills the test runner.

Throw a typed error from the domain code…

lib/src/core/upload.dart
import 'dart:io';
class AuthFailure implements Exception {
const AuthFailure(this.message);
final String message;
}
Future<void> upload(File artifact, HttpClient http) async {
final request = await http.putUrl(Uri.https('example.com', '/artifact'));
final response = await request.close();
if (response.statusCode == 403) {
throw const AuthFailure('Permission denied');
}
}

…and map it to an exit code in the one file that is allowed to:

// bin/tool.dart — not reported, bin/** is allowed by default.
import 'dart:io';
Future<void> main(List<String> args) async {
try {
await upload(File(args.first), HttpClient());
} on AuthFailure catch (e) {
stderr.writeln(e.message);
exit(3);
}
}

The branch is now testable:

test('a 403 is an auth failure', () {
expect(() => upload(artifact, http), throwsA(isA<AuthFailure>()));
});

Passing exit as a callback reaches the same place, one call later, so the bare name is flagged as well as the call:

lib/src/core/runner.dart
import 'dart:io';
void install(void Function(int) onFatal) {}
void wire() {
install(exit); // reported — same process death, one hop away
}

A repo that keeps its scripts in tool/ or its dev entrypoints in example/ lists them. allow_in replaces the default, so restate bin/** if you still want it:

many_lints.yaml
rules:
avoid_exit_outside_entrypoint:
allow_in:
- 'bin/**'
- 'tool/**'

Or add to whichever list won, without restating it:

many_lints.yaml
rules:
avoid_exit_outside_entrypoint:
additional_allow_in:
- 'tool/**'

Only dart:io’s top-level exit is reported. The element is resolved, so a Terminal().exit(3) of your own, Process.exit, or a local function named exit are never flagged.

A file the analyzer cannot place relative to the package root is left alone rather than guessed at — reporting on an unclassifiable path risks flagging the entrypoint itself.

analysis_options.yaml
many_lints:
rules:
avoid_exit_outside_entrypoint:
allow_in:
- 'bin/**'
- 'tool/**'
Option Type Default Description
allow_in list of globs ['bin/**'] Paths where exit() is permitted. Replaces the default
additional_allow_in list of globs [] Adds to whichever list won, without restating the default

Globs are matched against the path relative to the package root, with / as the separator on every platform.

This rule is in the recommended preset, so it is on with preset: recommended, preset: opinionated or preset: pedantic. Add it to preset: core with avoid_exit_outside_entrypoint: true.

To turn it off:

many_lints.yaml
rules:
avoid_exit_outside_entrypoint: false

To keep the rule on but skip certain paths, use per-rule exclude.