Skip to content

Latest commit

 

History

History

README.md

kaisel

codecov

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_stackConvert push() to run<T>() (the T is recovered from the route's KaiselModalRoute<T> implementation, so the result compiles in one shot).
  • require_route_propsAdd props override (generates the list from declared instance fields).
  • prefer_push_or_replace_top_in_adaptiveConvert push() to pushOrReplaceTop().
  • prefer_const_route_constructorsAdd const (inserts the keyword, or replaces a leading new).

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() to run<T>() — on any router.push(modalRoute) call.
  • Add props override — on any KaiselRoute subclass (useful before adding fields).
  • Convert push() to pushOrReplaceTop() — on any router.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_adaptive is opt-in by design. Whether a push should be pushOrReplaceTop depends 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 AnalysisRuleTest tests, and every fix and assist by end-to-end PluginServer tests 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.