kaisel_lint 0.5.1
kaisel_lint: ^0.5.1 copied to clipboard
Lint rules, quick fixes, and assists for the kaisel Flutter router, built on the first-party analysis_server_plugin API.
kaisel_lint #
Static analysis rules, quick fixes, and assists for the kaisel Flutter router, built as an analysis server plugin. Catches bug classes that the type system alone can't — modal routes pushed onto the main stack, route classes that forgot value equality, adaptive push calls that should be pushOrReplaceTop.
What ships #
Six lint rules, four quick fixes, three assists.
Lint rules #
| Rule | Default | Severity | What it catches |
|---|---|---|---|
avoid_modal_route_on_main_stack |
enabled | warning | router.push(modalRoute) instead of router.run<T>(modalRoute) |
require_route_props |
enabled | warning | KaiselRoute subclasses with fields but no props override |
prefer_push_or_replace_top_in_adaptive |
disabled | info | router.push(route) in projects using adaptive master-detail |
prefer_const_route_constructors |
disabled | info | a KaiselRoute construction that could be const but isn't |
prefer_pattern_match_over_is_check |
disabled | info | route is SomeRoute type tests that read better as a switch |
unused_guard_redirect |
disabled | info | a guard that returns the proposed stack unchanged (a no-op) |
avoid_modal_route_on_main_stack and require_route_props are the
recommended baseline once the plugin is enabled. The rest are opt-in: the
adaptive rule because we can't statically detect "this code path runs
under an adaptive builder"; prefer_const_route_constructors and
prefer_pattern_match_over_is_check because they're stylistic
preferences; and unused_guard_redirect because the no-op guard it
catches is uncommon (it's cheap insurance against a redirect branch that
silently returns the input unchanged).
Quick fixes (attached to lint diagnostics) #
When a lint fires, the IDE offers a one-click correction:
avoid_modal_route_on_main_stack→ Convertpush()torun<T>()(theTis recovered from the route'sKaiselModalRoute<T>implementation, so the result compiles in one shot).require_route_props→ Addpropsoverride (generates the list from declared instance fields).prefer_push_or_replace_top_in_adaptive→ Convertpush()topushOrReplaceTop().prefer_const_route_constructors→ Addconst(inserts the keyword, or replaces a leadingnew).
Assists (cursor-driven, no lint required) #
Available from the IDE's refactoring menu whenever the cursor sits on a qualifying construct, even when the related lint is disabled:
- Convert
push()torun<T>()— on anyrouter.push(modalRoute)call. - Add
propsoverride — on anyKaiselRoutesubclass (useful before adding fields). - Convert
push()topushOrReplaceTop()— on anyrouter.push(route)call where the route isn't modal.
Installation #
Add kaisel_lint to your dev_dependencies:
dev_dependencies:
kaisel_lint: ^0.3.0
Then either include the recommended config (activates the plugin with the correctness baseline on):
# analysis_options.yaml
include: package:kaisel_lint/recommended.yaml
…or activate it yourself under the plugins section (the entry is a map —
version: is the pub source, path: / git: also work):
# analysis_options.yaml
plugins:
kaisel_lint:
version: ^0.3.0
After modifying analysis_options.yaml, restart the Dart analysis
server (in VS Code: Dart: Restart Analysis Server; in IntelliJ:
File → Invalidate Caches & Restart).
Enabling and disabling specific rules #
Plugin-defined lint rules are off by default until explicitly enabled
under the plugin's diagnostics section. To opt in:
plugins:
kaisel_lint:
version: ^0.3.0
diagnostics:
avoid_modal_route_on_main_stack: true
require_route_props: true
prefer_push_or_replace_top_in_adaptive: false # opt-in per project
The two strong rules (avoid_modal_route_on_main_stack and
require_route_props) are the recommended baseline.
Suppressing individual occurrences #
Standard // ignore comments work, scoped by the plugin's namespace:
// ignore: kaisel_lint/avoid_modal_route_on_main_stack
router.push(const AddCardFlow());
Use this sparingly — the lints catch real bugs, and ignored occurrences are easy to forget.
What the rules catch #
avoid_modal_route_on_main_stack #
Pushing a KaiselModalRoute<T> via push() instead of opening it
via run<T>() "works" — the page renders — but silently loses the
typed completion contract. The caller of run<T> receives a
Future<T?> carrying the flow's result; the equivalent doesn't exist
on push. False-positive surface is near zero: a route only implements
KaiselModalRoute<T> when the author intended it as a flow.
// Before:
router.push(const AddCardFlow());
// → warning: typed-completion contract is lost
// After (quick fix applied):
router.run<CardId>(const AddCardFlow());
require_route_props #
KaiselRoute subclasses with instance fields must override props.
Without value equality, the stack treats two ProductDetail('a')
instances as distinct entries — breaking stack diffing,
pushOrReplaceTop's same-type detection, and equality-based
deduplication.
// Before:
final class ProductDetail extends AppRoute {
const ProductDetail(this.id);
final String id;
}
// After (quick fix applied):
final class ProductDetail extends AppRoute {
const ProductDetail(this.id);
final String id;
@override
List<Object?> get props => [id];
}
prefer_push_or_replace_top_in_adaptive #
In adaptive master-detail layouts, selecting a different detail with
push accumulates duplicates on the stack — list, detail-a, detail-b,
detail-c, … — instead of swapping in place. pushOrReplaceTop keeps
the stack two deep regardless of how many times the detail changes.
// Before:
onTap: () => router.push(ProductDetail(item.id));
// → info: in adaptive context, pushOrReplaceTop swaps in place
// After (quick fix applied):
onTap: () => router.pushOrReplaceTop(ProductDetail(item.id));
prefer_const_route_constructors #
Routes are value types the stack compares constantly, and const
instances are canonicalised so equal routes share identity. This is the
standard prefer_const_constructors scoped to KaiselRoute subtypes, so
you can enforce const routes without const-ing every class in the
project. It only fires when the construction can actually be const (const
constructor, constant arguments).
// Before:
final route = ProductDetail('sku-42');
// → info: this KaiselRoute construction can be const
// After (quick fix applied):
final route = const ProductDetail('sku-42');
prefer_pattern_match_over_is_check #
Routes are sealed value types, so branching on which concrete route is
held reads better as a switch — exhaustive, and it destructures fields
without a cast. The rule flags a positive is where both the tested
expression and the tested type are KaiselRoute subtypes. Capability
checks (route is KaiselModalRoute) and is! narrowing guards are left
alone.
// Before:
if (route is Home) return const HomeScreen();
if (route is ProductDetail) return ProductScreen(route.id);
// → info: prefer a pattern match over an is-check on a route
// After (your edit — there is no auto-fix):
return switch (route) {
Home() => const HomeScreen(),
ProductDetail(:final id) => ProductScreen(id),
};
There's no quick fix: the safe rewrite is contextual — a single check
becomes an if (route case ...), a chain becomes a switch — and naively
rebinding the variable can collide with a body-local of the same name.
unused_guard_redirect #
A KaiselGuard is (current, proposed) => stack. One that returns
proposed unchanged on every path does nothing — it's either dead code,
or a redirect branch that was meant to return a different stack but
silently returns the input.
// VIOLATION: a no-op guard.
final guard = (current, proposed) => proposed;
// CORRECT: actually redirects on one path.
final authGuard = (current, proposed) {
if (!loggedIn) return const [Login()];
return proposed;
};
To stay safe it's conservative: it only fires on a guard-shaped closure
(two List<R> parameters) whose body is purely returns and control flow.
A guard kept for a side effect — logging the navigation, say — has another
statement in its body, so it's left alone. There's no quick fix (removing
a guard means editing the guards: list).
Pre-1.0 caveats #
- API surface follows kaisel itself: until kaisel v1.0, rules may evolve as the library's conventions firm up.
prefer_push_or_replace_top_in_adaptiveis opt-in by design. Whether a push should bepushOrReplaceTopdepends on runtime layout width and the master-detail structure — a per-call decision the analyzer can't make for you — so the rule fires on every non-modal push. Enable it in projects using adaptive master-detail, where that reminder is signal rather than noise.- Each rule is covered by
AnalysisRuleTesttests, and every fix and assist by end-to-endPluginServertests that apply the change and assert the rewritten source.
Roadmap #
The rules originally scoped for the plugin have all shipped. Further rules track kaisel's own conventions as they firm up toward v1.0.