Dart Made Analyzer Plugins Official. Migrating Two Linters Was Not a Package Rename

Dart 3.10 added an official analyzer plugin system. I maintain two lint packages, so the migration looked almost suspiciously tidy: replace the archived host dependency, rename a few base classes, and enjoy the future.
That was the optimistic version. The analyzer had other plans (of course it did).
The host was not just a dependency. It owned configuration, plugin discovery, diagnostic activation, test infrastructure, and part of the public API. Moving the packages meant changing every one of those contracts without changing what the rules reported.
The old host was everywhere
Both packages used custom_lint_builder. Each exposed a createPlugin() function, returned a PluginBase, and registered DartLintRule instances through getLintRules().
Consumers configured that host under analyzer.plugins and enabled rules through custom_lint.rules. Tests could instantiate the rule classes directly and conclude that the package was healthy.
The official Dart plugin system uses a different shape:
final plugin = MyPlugin();
class MyPlugin extends Plugin {
@override
String get name => 'my_plugin';
@override
void register(PluginRegistry registry) {
// Register diagnostics, fixes, and assists here.
}
}
That top-level plugin variable in lib/main.dart is not decoration. The analysis server looks for it when it loads the package.
The consumer configuration also moves to a top-level block:
plugins:
my_plugin:
version: ^1.0.0
diagnostics:
my_rule: true
This block belongs in the analysis_options.yaml at the consumer package or workspace root. A local plugin can use path: instead of version:.
Lint diagnostics are disabled until the consumer opts in. A package can load correctly and still report nothing because its diagnostic map is absent. Green and useful remain two different states (a recurring theme in lint tooling).
Round 1: preserve behavior before changing the host
The safest first step was not migration code. It was characterization.
The repository recorded the raw diagnostic codes, severities, public rule names, constructors, and known positive and negative cases before replacing the host. That gave the migration a stable question: does the official host report the same findings for the same source?
Without that baseline, every changed diagnostic could be explained away as an intentional improvement. That is a very convenient way to lose a lint rule.
The migration kept five diagnostic codes in each package and retained INFO severity. It also kept the public rule classes and zero-argument constructors. The plugin entrypoint and registration surface changed because those were host contracts, not rule behavior.
Round 2: package tests were not enough
A direct rule test proves that rule logic can inspect an analyzed unit. It does not prove that a real consumer can install the plugin, load lib/main.dart, activate a diagnostic, and receive output from dart analyze.
So the repository added a standalone consumer harness. It creates a temporary consumer, selects either a local path or a hosted package version, writes the plugin configuration, runs the analyzer repeatedly, and checks the resulting diagnostic.
The important part is the boundary it crosses:
package unit test
-> temporary consumer
-> dependency resolution
-> analysis server plugin loading
-> configured diagnostic
-> analyzer output
That chain is the product. A lint package that only works when its own test imports internal classes is not a working plugin.
Round 3: one analyzer command passed and the other did not
At the repository state checked on September 8, 2026, the local consumer harness passed with dart analyze on the tested dependency family. The package changelogs record analysis_server_plugin 0.3.22, analyzer 14.3.0, and analyzer_plugin 0.14.16 as the resolved versions.
flutter analyze remained blocked by Flutter issue #187999. The authored operating-system and SDK matrix had not run in GitHub Actions either. Publication therefore remained blocked, and the migration stayed in unreleased changelog sections.
That is not an awkward footnote to hide. It is the difference between “the code was migrated” and “the package is ready to publish.”
The current evidence establishes one working path on one local toolchain. It does not establish a minimum supported Flutter release or cross-platform compatibility.
What actually changed
The migration produced four practical lessons.
First, configuration is part of a plugin's API. Moving from nested legacy settings to top-level plugins: is a breaking consumer change even when every diagnostic code stays identical.
Second, activation defaults matter. An analyzer can load a plugin perfectly and report zero lints because the consumer did not enable them.
Third, the plugin entrypoint deserves an integration test. A class hierarchy test cannot prove that the analysis server discovered the package.
Finally, “works with Dart” does not automatically mean “works with Flutter.” Run both commands through the same disposable consumer and record them separately.
Dart's official host is the right direction. It gives plugin authors a supported path for diagnostics, fixes, and assists. It also makes the real integration boundary more visible.
If you are migrating a custom lint package, do not begin with search and replace. Freeze one positive diagnostic, one near-miss, and one real consumer first. Then change the host.
The compiler will tell you when the class names are wrong. Only the consumer can tell you whether your plugin still exists.
Get the next post.
If you made it to the end, meet the next post in your inbox or RSS reader.