analysis_options.yaml sits at the package root and configures the Dart analyzer: which ruleset you inherit, which extra lints run, how severe each diagnostic is, which files are skipped, and the formatter's page width.
**include:**: inherit a published ruleset, such as package:flutter_lints/flutter.yaml
**linter: rules:**: enable extra rules, or switch an inherited one off with rule_name: false
**analyzer: errors:**: set any diagnostic to error, warning, info or ignore
**analyzer: exclude:**: skip generated files such as **/*.g.dart
**analyzer: language:**: stricter type checks: strict-casts, strict-inference, strict-raw-types
**formatter:**: page_width for dart format, shared by the IDE, the CLI and CI
The Dart tools read this file (analyzer, dart fix, dart format, the IDE); the compiler ignores it, so a real compile error fails the build whatever the file says.
FlutterMiddle
Lints, Analyzer & Code Review
analysis_options, lint rulesets, dart fix, dart format, custom_lint, annotations, PR review
Questions closed
0 / 21
- 0Review
Questions
dart analyze reports problems without touching the code, dart fix applies the automated fixes the analyzer knows for those problems, and dart format rewrites whitespace and line breaks into the one official style.
**dart analyze**: prints errors, warnings, infos and lints; exits non-zero on errors and warnings, and on infos only with --fatal-infos. flutter analyze is the same tool with infos fatal by default
**dart fix**: --dry-run lists what it would change, --apply changes it, --code=<rule> limits it to one rule. It also applies the API migrations packages ship in fix_data.yaml
**dart format**: almost no settings besides the page width; --output=none --set-exit-if-changed turns it into a CI check
Order: fix, then format, then analyze: a fix can leave a line that needs reformatting
Six infos: a missing key, a constructor that could be const, a print, a Container that is only a sized box, a Container that does nothing, and a colour with too few hex digits. flutter analyze treats infos as fatal, so CI fails.
**use_key_in_widget_constructors**: add super.key: const Greeting({super.key, this.name});
**prefer_const_constructors_in_immutables**: widgets are @immutable, so the constructor should be const; callers can then write const Greeting()
**avoid_print**: print also runs in release builds; use log from dart:developer or a logging package
**sized_box_for_whitespace**: a Container with only a size and a child is a SizedBox, which is lighter and can be const
**avoid_unnecessary_containers**: a Container with no properties adds an element for nothing; use the Text directly
**use_full_hex_values_for_flutter_colors**: Color(0xFF00) is 0x0000FF00: alpha 00, a fully transparent green, so the star is invisible. Write all 8 digits: Color(0xFF00FF00)
All three are packages with a ready-made ruleset you include: lints is the Dart team's baseline, flutter_lints adds Flutter rules on top and comes with flutter create, and very_good_analysis is a much stricter third-party set.
**lints**: core.yaml (about 35 rules that are almost always bugs) and recommended.yaml (common style on top); meant for pure Dart packages
**flutter_lints**: lints/recommended plus 10 Flutter rules: avoid_print, use_key_in_widget_constructors, use_build_context_synchronously, sized_box_for_whitespace and others
**very_good_analysis**: close to 200 rules plus strict-casts, strict-inference and strict-raw-types; adds public_member_api_docs, lines_longer_than_80_chars, prefer_const_constructors, unawaited_futures
How to choose: flutter_lints plus a few rules you opt into is the common middle ground; the strict set suits libraries and teams that want the tool to settle every style argument
Upgrades: new rules arrive in major versions, so bump the package in its own PR and run dart fix --apply in it
Under analyzer: errors: you map any diagnostic code to error, warning, info or ignore. Promote the rules whose violations are runtime bugs a reviewer cannot see in a diff; leave style rules at info.
Defaults: lints report as info; dart analyze fails on errors and warnings, flutter analyze on infos as well. Severity changes the exit code and the IDE colour, not what is found
Worth promoting: use_build_context_synchronously, unawaited_futures, unrelated_type_equality_checks, hash_and_equals: each one is a crash or a silently wrong result
Not worth promoting: public_member_api_docs, lines_longer_than_80_chars: a style rule that fails the build gets silenced with blanket ignores; keep it at info and fix it gradually
Enable before you promote: errors: only sets severity; a rule missing from linter: rules: reports nothing at any severity
The opposite mistake: deprecated_member_use: ignore keeps CI green by deleting the only reminder that a migration is pending
**cannot-ignore:**: lists codes that no // ignore comment is allowed to suppress
A small group: lints that catch comparisons that are always false, broken equality, futures nobody awaits, BuildContext used after the widget is gone, and secrets printed to the device log. Most of the rest is readability.
**unrelated_type_equality_checks, collection_methods_unrelated_type**: '7' == 7 and ids.contains('7') on a List<int> compile and are always false
**hash_and_equals**: overriding == without hashCode makes a Set or Map keep two "equal" objects
**unawaited_futures**: a forgotten await in an async function: its error skips your catch, and the next line runs before the write finishes. It checks only async bodies; discarded_futures covers sync ones. Neither is in flutter_lints
**use_build_context_synchronously**: context used after an await, when the widget may already be unmounted; check context.mounted first
**avoid_print**: print ships in release builds, so tokens and response bodies land in logcat and the iOS console
ApiFailure is a plain enum, not an Exception, so on Exception catch does not match it. The error escapes loadProfile: the screen stays in its loading state and ApiFailure.notFound surfaces as an uncaught error.
Why it happens: Dart can throw any non-null object, and on T matches only by type. ApiFailure neither implements Exception nor extends Error, so the clause skips it; called from initState or a button handler, the failed Future becomes an uncaught async error
The fix: enum ApiFailure implements Exception, or class ApiFailure implements Exception for a class. The throw sites and the catch stay as they are
Catch it next time: enable only_throw_errors: it reports every throw of an object that is not an Exception or an Error. It is in very_good_analysis, not in flutter_lints
**Do not switch to a bare catch (e)**: it also swallows TypeError and StateError, which are bugs to fix, not states to show; avoid_catches_without_on_clauses flags it
Test: a MockClient from package:http/testing.dart that answers 404, then expect(profile.value, isA<ProfileError>())
// ignore: silences one rule on one line, // ignore_for_file: silences it for the whole file including code written later, and exclude: drops files from analysis entirely, errors included. In review, accept the narrowest form with a reason beside it.
**// ignore: rule: on the line above, or at the end of the same line; the reviewer sees exactly what is hidden
// ignore_for_file: rule**: fine for generated or vendored code; in hand-written code it also hides every future violation. ignore_for_file: type=lint silences all lints at once
**analyzer: exclude:**: right for *.g.dart and build/; wrong for a legacy folder you plan to clean, because real errors there vanish too
Ignores can hide errors: // ignore: invalid_assignment makes dart analyze green while the compiler still rejects the line
Keep them honest: enable unnecessary_ignore to report ignores whose diagnostic is gone, and list rules nobody may silence under cannot-ignore:
They are metadata the analyzer reads: @override comes from dart:core, the others from package:meta, which Flutter's foundation re-exports. Breaking them gives analyzer warnings, not compile errors: dart analyze fails CI on them, but flutter run and flutter build still compile the code.
**@override**: marks an intended override; if nothing is overridden (say, the base method was renamed) you get override_on_non_overriding_member. The annotate_overrides lint makes it mandatory
**@immutable**: every field of the class and its subclasses must be final (must_be_immutable); the check is shallow, so a final List can still be mutated
**@protected: usable only in its own library and in subclass instance members; not an access modifier, the member is still public at run time
@visibleForTesting: usable only in its own library and in tests; nothing is stripped from the release build
@mustCallSuper**: an override must call super; the check only looks for the call, so if (kDebugMode) super.dispose(); passes
Make them stick: run dart analyze in CI and keep warnings fatal (the default); only a --no-fatal-warnings run lets them through
ChatViewModel.dispose overrides the base method without calling super.dispose(), so the subscriptions made by watch are never cancelled. The repository's stream keeps every closed chat's view model alive and keeps adding messages to it.
Why it leaks: an override replaces the base method, and Dart never calls super for you. The stream holds the subscription, the subscription holds the messages.add tear-off, and that holds the view model, so the GC cannot collect it
The fix: release the subclass's own resources first, then await super.dispose(), the same order as in Flutter's State.dispose
Catch it next time: annotate the base dispose with @mustCallSuper from package:meta (re-exported by flutter/foundation.dart). An override without the super call then gets the must_call_super warning, and dart analyze fails CI on warnings. State.dispose and ChangeNotifier.dispose already carry it, which is why this slip is caught in widgets but not in your own base classes
Test: back the fake repository with a StreamController, dispose the view model and expect(controller.hasListener, isFalse)
Add the new name, keep the old one as a forwarding member marked @Deprecated('Use X instead'), and delete it only in the next major version. Ship a fix_data.yaml so dart fix --apply rewrites the callers for them.
Always give a message: @Deprecated('Use parseUser instead. Removed in 3.0.0.'); the bare @deprecated names no replacement, and provide_deprecation_message flags it
What callers see: deprecated_member_use, an info: it fails flutter analyze and dart analyze --fatal-infos, never compilation
Inside your own package: the code is deprecated_member_use_from_same_package, so you can quiet your own transition without hiding anything from consumers
One implementation: the old member forwards to the new one; two copies drift, and the deprecated path ships a bug
Semver: adding the new name is a minor release, removing the old one is a major
Let the tool decide: everyone runs dart format, CI fails on any diff, and nobody argues about layout in review. Dart 3.7 introduced a new "tall" style picked by the package's language version, so raising the SDK constraint reformats the whole repository.
One place for settings: formatter: page_width: in analysis_options.yaml, so the IDE, the CLI and CI agree without anyone passing --line-length
CI gate: dart format --output=none --set-exit-if-changed .; format-on-save in the editor catches the rest earlier
What the tall style changed: the formatter now adds and removes trailing commas itself, so a trailing comma no longer forces one argument per line; Dart 3.8 added trailing_commas: preserve for teams who want the old behaviour
Landing the reformat: one commit with no logic in it, its hash in .git-blame-ignore-revs (GitHub reads it; locally set blame.ignoreRevsFile), merged when few branches are open
Beyond layout: naming and API style come from Effective Dart; the lints encode most of it, so review comments can point to a rule instead of taste
CI is the gate and the hook is a convenience: hooks live in .git/hooks, which is never cloned, and git commit --no-verify skips them by design. Put fast, self-fixing checks on staged files in the hook and the full checks in CI.
In the hook: dart format on the staged files, perhaps dart analyze on them; keep it to a couple of seconds or people start skipping it
Re-stage what the hook rewrote: otherwise the commit keeps the unformatted version; lefthook's stage_fixed: true does this, and forgetting it is one common reason unformatted code still lands
In CI: the whole repository, no auto-fixing, a non-zero exit: the format check, flutter analyze, any custom lint step, then the tests
Make the hook installable: commit the lefthook or husky config, or a .githooks/ folder plus git config core.hooksPath .githooks, and run the install from the project's setup script
Rule of thumb: a check that can only pass on someone's laptop is not a check
The tool is the same; its inputs differ. Look at the SDK version, the resolved package versions, the files on disk, and which directories are analysed with which flags.
SDK version: the IDE uses whatever SDK it points at, CI whatever its image installs; a new SDK adds diagnostics without anyone editing a line. Pin it with FVM or flutter-version-file in subosito/flutter-action
Resolved dependencies: without a committed pubspec.lock (usual for packages) the runner resolves the newest allowed versions, and a member may be deprecated only there; flutter pub get --enforce-lockfile fails instead of resolving silently
Files on disk: .g.dart files exist on your machine and not in a fresh clone, and a file you forgot to git add analyses fine for you only
Scope and flags: the IDE shows infos but never fails, dart analyze fails on warnings, flutter analyze also on infos; in a monorepo each one may cover a different set of packages
How to reproduce: clone your branch into a temporary directory and run the exact CI commands there
The analyzer only proves things inside one library: unused imports, private members and dead branches. Public classes, packages and assets need whole-project tools, and anything reached through a string stays invisible to all of them.
Free already: unused_import, unused_local_variable, unused_field, unused_element, dead_code
Private names only: a public class nobody imports is not reported, because another package could import it tomorrow
Needs a tool: DCM's check-unused-code and check-unused-files for public code; dependency_validator for packages nothing imports
What actually shipped: flutter build apk --analyze-size lists every package and asset in the artefact, and DevTools opens the JSON it writes
What nothing can know: Image.asset('icons/$name.png'), a route table built from a map, a method that native code calls by name over a MethodChannel. Treat every "unused" report as a question, and delete in a commit of its own so the revert is one click
Five problems no lint catches: the controller is never disposed, a failed fetch is never handled, setState can run after the widget is gone, the repository is built inside the widget, and a new orderId is ignored.
**_note is never disposed**: there is no dispose(); add one that calls _note.dispose() before super.dispose()
No error path: if fetch throws, the error is unhandled and the user sees "Loading" forever
**setState after await**: if the user leaves first, Flutter throws "setState() called after dispose()"; check if (!mounted) return; or let FutureBuilder handle it
**OrderRepository() inside the widget: a test cannot replace it; inject it through the constructor or your DI
No didUpdateWidget**: when the parent rebuilds with another orderId, the page keeps showing the old order
Nit: 'Loading' is user-visible text outside the l10n files
Hold it to the same bar as any PR, aimed at where generated code goes wrong: unvetted dependencies, code that ignores the project's conventions, silenced errors and security checks, and tests that pass whatever the code does. The author must be able to explain every line.
New packages: open each one added to pubspec.yaml on pub.dev and check the publisher, the last release and the licence; assistants invent plausible names, and attackers register them
Current but not ours: deprecated APIs such as WillPopScope at least trip deprecated_member_use; Navigator.push in a go_router app, setState in a Bloc screen or raw colours instead of theme tokens trip nothing
Swallowed errors: a bare catch that only logs makes a failed save look like a success, and a ! or as added to quiet the analyzer moves the crash to production
Weakened security: badCertificateCallback returning true, NSAllowsArbitraryLoads or usesCleartextTraffic added to "fix the network", a key pasted into Dart; read the diff under ios/ and android/ as closely as lib/
Lifecycle: controllers without dispose, setState after an await without a mounted check; no rule in flutter_lints reports either
Tests that prove nothing: a findsOneWidget on the page itself, mocks that return whatever the code expects, goldens regenerated in the same PR; revert the change locally and see whether any test fails
Let tools own formatting and lints, and spend human attention on what they cannot see: state and lifecycle, failure paths, layer boundaries and tests. Give every comment a severity and a reason or a question, not just a verdict.
What to look for
Ownership: every controller, subscription and notifier created here is disposed here, and the widget handles new arguments in didUpdateWidget
State shape: can the class be "loading" and "error" at once? One sealed state makes the impossible state unrepresentable
Unhappy paths: no network, permission denied, an empty list, large text scale, rotation mid-request; run the branch, do not only read it
Boundaries: a DTO or Dio leaking into widgets, business logic in build, user-visible strings outside l10n
Tests: would a test fail if this change were reverted?
How to phrase it
Label severity: blocking:, suggestion:, nit:, and approve with comments when only nits remain
Point to a standard, not taste: link Effective Dart or the team guide; a comment you keep repeating should become a lint
Ask before prescribing: "What does the user see if this request fails?" teaches more than a rewritten snippet
Review the code, not the person: "this method", not "you"
What the author owes the reviewer
Small PRs: under about 400 changed lines, with refactors separate from behaviour changes
Context: what and why in the description, screenshots or a video for UI changes, and a self-review before asking
Write an analyzer plugin that walks the resolved AST and reports a diagnostic. Since Dart 3.10 there is an official plugin system run by dart analyze itself; many teams still use the older custom_lint, which needs its own CI step.
Two ways to ship it
**custom_lint**: rules in a package built on custom_lint_builder, enabled under analyzer: plugins: - custom_lint. dart analyze does not run it, so CI needs dart run custom_lint, or the rule exists only in someone's IDE
Official plugins (Dart 3.10+): a package built on analysis_server_plugin, listed in a top-level plugins: section of analysis_options.yaml; the IDE and dart analyze run it
Writing the rule
Ask about types, not spelling: the AST is resolved, so you can check what a type is and where it was declared; that is what a grep cannot do
Bail out early: rules run on every keystroke in the IDE, so filter by path before visiting nodes
Ship a quick fix when the fix is mechanical, and start the rule at info, promoting it under analyzer: errors: once the code base is clean
Limits
One library at a time: it cannot answer "is this used anywhere"
Generated code: it does not see what build_runner has not generated yet
Cost: every rule is code to maintain; write one only for a mistake that keeps coming back and causes real bugs
Use metrics as a signal on the pull request, not as a hard gate: a number tells you which code is worth reading, not whether it is good, and a hard threshold gets gamed.
Cyclomatic complexity fits Flutter badly: a 400-line build with one if scores 2, while a switch over ten sealed states scores 11 and is the clearest code in the file
Easy to game: splitting build into _buildHeader and _buildBody methods passes every threshold and makes things worse: those helpers cannot be const and rebuild with the parent; extracting widget classes is the real fix
What tracks trouble better: nesting depth and parameter count; they are hard to hit by accident and hard to fake away
Configure it to survive: exclude generated files and tests, set each threshold where it flags roughly the worst few percent of today's code, and report it as a PR comment (DCM can do this) rather than through the exit code
Rule of thumb: metrics choose what gets read; people decide what gets changed
Turn it on in stages: apply dart fix first, keep the remaining rules visible but non-blocking for old code and blocking for code the PR touches, and burn the backlog down rule by rule in small PRs.
First pass
**dart fix --apply**: clears the mechanical share in a commit that reviews as "machine-generated, no manual edits"; --code=<rule> splits it further if it is still too large
Rank the rest: dart analyze --format=machine prints pipe-separated fields, so one line of shell counts issues per rule; take the real-bug rules first, whatever their count
Stop it growing
New code at the full bar: CI runs dart analyze on the whole repo with infos non-fatal, and dart analyze --fatal-infos on the files the PR changed; everyone still sees the infos in the IDE
A visible baseline for the noisiest rules: Dart has no baseline file, so rules with thousands of hits go to ignore under analyzer: errors:, with a count and a ticket beside each line, and come out one PR at a time. An ignored rule is off for new code too, so keep this list short and shrinking
Legacy folders: a nested analysis_options.yaml there (the nearest file wins) instead of exclude:, which would also hide real errors
Keep history readable
Blame: mass-fix commits go in .git-blame-ignore-revs
Timing: merge them when few branches are open, so nobody resolves conflicts by hand