genesis_lint
Static analysis rules that keep BuildContext capabilities scoped safely and
keep tree code declarative: a build describes children, effects live in
leaves, and configuration is read from the tree rather than cached or
constructed bare. This package is an analyzer extension; applications do not
import it at runtime, and it deliberately has no dependency on genesis_tree
or genesis_foundation. Rules match the resolved declaring-library URI of the
types and annotations they inspect, so a same-named type or annotation from
another package is never matched.
Analyzer extensions are available on Dart 3.10 and later. Enable this one in
the top-level plugins section of a package-root or workspace-root
analysis_options.yaml:
plugins:
genesis_lint: ^0.3.0-dev.1
For local development, replace the version with a path dependency:
plugins:
genesis_lint:
path: packages/lint
Every rule is registered as a warning rule, so each is enabled without a
diagnostics mapping. The six tree-shape rules, from no_effects_in_build
on, report at warning severity, so dart analyze exits non-zero when one of
them fires; the two context rules report at info. None of them reports in a
package's test/ directory.
no_stored_tree_context
Rejects a BuildContext assigned to durable object state, inserted into a
collection, or captured by an escaping closure. The ban is unconditional:
checking mounted does not make retaining a write-capability handle on a
long-lived unmanaged object safe. Framework storage owned by a Element or a
BuildContext implementation is exempt.
use_tree_context_synchronously
Rejects a BuildContext use after await unless the same handle has had an
intervening mounted probe. Holding the handle across an asynchronous gap is
legal when it is checked before use:
Future<void> rebuildLater(BuildContext context) async {
await waitForWork();
if (!context.mounted) return;
context.markNeedsRebuild();
}
A second await invalidates the earlier probe and requires another check.
no_effects_in_build
Rejects an effect inside the build (or buildWithChild) of a
genesis_tree Component or State subclass: an invocation whose static
type is Future or FutureOr, a Timer construction or static call,
scheduleMicrotask, Stream.listen, setState, await, and an assignment
or ++/-- that outlives the build — a field of this, a top-level or
static variable, or a property or index of any object the build did not make.
Constructing components and building local values stay legal: a write
through a local variable, or into a literal or constructor call, including
as a cascade (<String, int>{}..['a'] = 1), is local. So is a write through
the accumulator of a fold whose initial value is such a local value, as in
names.fold(<String, int>{}, (acc, name) => acc..[name] = name.length); a
fold seeded with a field is still reported.
The rule checks the build's own body, a closure invoked on the spot, a closure handed
to any dart:core or dart:collection method or to a generate or
fromIterable constructor of those libraries, and a local function the
build calls or hands to such a method. Most of those methods run the closure
before they return (forEach, fold, sort); the lazy Iterable methods
(map, where, expand, takeWhile, skipWhile) run it only when the
result is iterated, and are checked anyway, because a lazy iterable made in a
build is consumed there. A closure handed anywhere else — a component's
callback, an effect hook — runs later and is not checked.
The rule does not see through aliases or dynamic dispatch: a field written
through a local that aliases it, a closure stored in a variable and then
called, a callback run by a non-core helper such as package:collection's
forEachIndexed, and a Future obtained from a getter are not reported.
Reading a Future is not starting one, and passing an existing Future to
a component is legal.
@override
Component build(BuildContext context) => switch (phase) {
Idle() => const Waiting(),
Running(:final command) => Spawn(command: command),
};
The process starts in Spawn's Element, not in the build that emits it.
no_cached_dependency
Rejects a ??= into a field, a top-level variable, or a property of an
object the code did not just make, whose right-hand side calls a member on a BuildContext — dependOnInheritedValueOfExactType,
getInheritedValueOfExactType, watch, read — or passes a BuildContext
to any invocation. The cache freezes the first value it saw, so a change to
the provided value never propagates. A ??= into a local is not a cache, and
caching a BuildContext handle itself is left to no_stored_tree_context.
// Rejected: _config never sees a new Config.
_config ??= context.watch<Config>();
// Accepted: read where it is used.
final config = context.watch<Config>();
watch_not_read_in_build
Rejects getInheritedValueOfExactType and the provider read inside a
build. A dependency-free read does not register the element as a dependent,
so the build never reruns when the value changes. Use
dependOnInheritedValueOfExactType or watch in a build; the snapshot reads
belong in initState, callbacks and effects.
derive_dont_construct
Rejects a constructor call — new, const, named, factory, a tear-off such
as Posture.new, or an explicit super(...) — of a class annotated
@deriveOnly (from genesis_foundation) from outside the library that
declares it. A value that composes down the tree is reached through derive
or copyWith on the ambient value, so a field set above is never silently
dropped:
@deriveOnly
final class Posture {
const Posture._({required this.tier});
final int tier;
Posture copyWith({int? tier}) => Posture._(tier: tier ?? this.tier);
}
Parts of the declaring library may construct the class.
state_flag_threshold
Rejects a genesis_tree State subclass that declares more than four
instance fields of type bool or bool?. Flags admit combinations no phase
allows; model the phases as a sealed state value and switch over it
exhaustively in build. Only fields the class itself declares are counted.
effects_only_in_leaves
Rejects an invocation of a method or function annotated @effect (from
genesis_foundation), or of an override of one, from anywhere but a
sanctioned site. The sanctioned sites are an @effect declaration, so
effects compose, and, inside a class or mixin annotated @effectLeaf, its
lifecycle methods — startOrAdopt, update and dispose — and the methods,
getters and setters of the same class that a lifecycle method calls or tears
off, directly or through each other. These are the lifecycle names of the
effect-leaf Element contract an orchestrator declares on its own leaf base
class; genesis_tree's Element does not declare startOrAdopt. A setter
counts as reached when a lifecycle method assigns through it, and a getter
and setter both when one applies a compound assignment, ++ or --.
The mark is inherited, so annotating that base class makes its subclasses leaves, and a subclass's own override of a lifecycle method is a lifecycle entry point. Reachability follows the hierarchy within one file: for a leaf class, the members it and its supertypes declare in that file are walked together, so a base's lifecycle method reaches a subclass's override of the hook it calls, and a subclass's lifecycle method reaches a helper the base declares. Members declared in another file — a base class imported from another library, or declared in a different part of the same library — are not walked: a helper such a base declares, or a hook such a base's lifecycle calls, is reported when it invokes an effect. A leaf's constructor, field initializers, build and any member the lifecycle does not reach are not sanctioned.
@effectLeaf
abstract class ProcessElement extends Element {
void startOrAdopt() => _spawn();
void _spawn() => spawnProcess(command);
}
Dispatch is resolved statically: a call through a supertype whose member is not annotated is not reported, even when an override is.
Libraries
- genesis_lint
- Static analysis rules for safe genesis tree context use.
- main