Rules
loam.dev ships thirteen analysis capabilities, all behind one stableRule interface. Adding a new rule never changes the pipeline — only the rule list. Eleven rules are live today; two are planned and will ship as post-MVP iterations. Status is always honest.
Live
Unused public exports
Finds public API members — classes, methods, getters/setters, fields, enums, typedefs — that have no references anywhere in the project. Analysis runs on the resolved element model, not regex, so generated-code inputs (Drift, freezed, Riverpod, json_serializable) are automatically excluded without any configuration.
// public but never used outside this library
class OldHelper {
static String format(String s) => s.trim();
}
// also unreferenced — reported by loam.dev
enum LegacyStatus { active, archived }// either remove it, make it private, or add
// a loam-ignore directive with a reason:
// loam-ignore: unused-public-exports – re-exported via barrel
// kept and used:
class ActiveHelper {
static String format(String s) => s.trim();
}Circular dependencies
Detects import cycles across your Dart libraries. Cycles make compilation order ambiguous, prevent tree-shaking, and are a common sign of unclear layer responsibilities.
// lib/a.dart
import 'b.dart';
// lib/b.dart
import 'a.dart'; // ← cycle: a → b → a// Extract shared types into a third library
// lib/shared.dart (no imports from a or b)
// lib/a.dart imports shared.dart
// lib/b.dart imports shared.dartCode duplicates
loam.dev finds duplicated code blocks using AST-normalised token hashing — exact copies (Type-1) and structurally identical blocks with renamed identifiers or literals (Type-2). Each Finding covers one cluster with all occurrence locations, so the baseline tracks the whole group as a single entry. A classic AI-agent side effect: helpers copy-pasted rather than extracted.
// feature_a/utils.dart
String formatDate(DateTime d) =>
'${d.day}.${d.month}.${d.year}';
// feature_b/helpers.dart ← near-duplicate
String renderDate(DateTime d) =>
'${d.day}.${d.month}.${d.year}';// lib/shared/date_format.dart
String formatDate(DateTime d) =>
'${d.day}.${d.month}.${d.year}';
// both features import the shared helper
// loam-ignore: code-duplicates – intentional symmetryComplexity hotspots
Measures cyclomatic and cognitive complexity per function/method and aggregates them into a project health score. Flags functions that are too complex to review or test reliably.
// cognitive complexity ≫ threshold
Future<void> syncData(List<Item> items) async {
for (final item in items) {
if (item.isValid) {
if (item.needsSync) {
try {
if (await remote.has(item.id)) {
await remote.update(item);
} else { await remote.create(item); }
} catch (e) { /* swallowed */ }
}
}
}
}// split into focused, testable helpers
Future<void> syncData(List<Item> items) async {
final candidates = items.where(_needsSync);
await Future.wait(candidates.map(_syncOne));
}
Future<void> _syncOne(Item item) async { … }Form field without label
Flags Flutter TextField and TextFormField widgets that have neither a decoration: InputDecoration(labelText: …) (nor hintText as a fallback) nor an enclosing Semantics(label: …) ancestor. WCAG 1.3.1 (Info and Relationships), 3.3.2 (Labels or Instructions), and 4.1.2 (Name, Role, Value) all require form fields to have a programmatically determinable label. Analysis runs on the resolved element model — local classes named TextField are never flagged. Conservative: a decoration: that is not a literal InputDecoration(…) creation is left alone. AST-only: runtime contrast, focus order, touch-target sizes, and dynamic decoration values are out of scope.
// no decoration at all — screen reader has no label to announce
TextField()
// InputDecoration present but no labelText or hintText — still missing
TextField(
decoration: InputDecoration(),
)// labelText provides the primary accessible label
TextField(
decoration: InputDecoration(labelText: 'Email address'),
)
// hintText accepted as fallback (e.g. search fields)
TextField(
decoration: InputDecoration(hintText: 'Search…'),
)
// or wrap with Semantics for custom label delivery
Semantics(
label: 'Email address',
child: TextField(),
)Image without semantic label
Flags Flutter Image / Image.asset / Image.network / Image.file / Image.memory widgets that carry no semanticLabel and are not excluded from the accessibility tree via excludeFromSemantics: true. WCAG 1.1.1 (Non-text Content) requires every non-decorative image to have a text alternative. Analysis runs on the resolved element model, so local classes named Image are never flagged.
// no semantic label — screen reader reads nothing
Image.asset('assets/hero.png')
// explicit empty label still gets flagged
Image.network('https://example.com/photo.jpg',
semanticLabel: '', // ← empty = missing// describe what the image conveys
Image.asset('assets/hero.png',
semanticLabel: 'App hero illustration')
// purely decorative: opt out explicitly
Image.asset('assets/divider.png',
excludeFromSemantics: true)Interactive widget without semantics
Flags generic and custom (non-Flutter) interactive widgets that carry an onTap, onPressed, or onLongPress callback but have no enclosing Semantics(label: …) ancestor. WCAG 4.1.2 (Name, Role, Value) requires every interactive control to have a programmatically determinable name. Analysis runs on the resolved element model — Flutter built-in widgets already covered by sibling rules (IconButton, GestureDetector, InkWell, TextField, TextFormField) are excluded from this rule. AST-only: out of scope are widgets that become interactive via inherited callbacks, custom gesture recognisers that bypass the named parameters, and accessible-name mechanisms other than Semantics(label: …).
// custom tappable card — screen reader has no label
CustomCard(
onTap: () => showDetails(item),
)
// custom button with onPressed — also unnamed
ActionChip(
onPressed: () => doAction(),
)// wrap with Semantics to give it an accessible name
Semantics(
label: 'Show item details',
button: true,
child: CustomCard(
onTap: () => showDetails(item),
),
)
// or ensure the widget carries a label-providing property
ActionChip(
label: Text('Do action'),
onPressed: () => doAction(),
)Empty or comment-only catch block
Flags catch blocks whose body is empty ({}) or contains only comments — both silently swallow exceptions. This extends the built-in empty_catches lint (which only catches the literal-empty variant) to also flag the comment-only variant that AI agents commonly produce when suppressing a catch with a filler comment. Severity: warning. Bodies containing a rethrow, throw, or any logging call are never flagged (conservative allowlist). Generated files (.g.dart, .freezed.dart, …) are skipped automatically.
// literally empty — exception vanishes
try {
await fetchData();
} catch (e) {}
// comment-only — same problem, missed by empty_catches
try {
await fetchData();
} catch (e) {
// TODO: handle this
}// log and rethrow — preserves context
try {
await fetchData();
} catch (e, st) {
log('fetchData failed', error: e, stackTrace: st);
rethrow;
}
// or wrap in a domain error
try {
await fetchData();
} catch (e) {
throw DataLoadException(cause: e);
}Unjustified // ignore: directive
Flags // ignore: and // ignore_for_file: directives that have no written justification — neither inline text after the lint names nor a comment on the preceding line. Groundless suppress directives are a common AI-agent shortcut: the agent silences the linter rather than fixing the root cause. Severity: info. Generated files (.g.dart, .freezed.dart, …) are skipped automatically.
// no reason given — why is this lint suppressed?
// ignore: avoid_print
print('debug output');
// file-wide suppress with no justification
// ignore_for_file: prefer_const_constructors// inline reason: tells reviewers why the suppress is intentional
// ignore: avoid_print – CLI output path, not a debug statement
print('done.');
// reason on the line above is also accepted
// Suppress: this file predates null-safety and is scheduled for migration.
// ignore_for_file: avoid_printNarrative filler // comment
Flags // comments immediately before a declaration whose text restates the name (e.g. // build before void build()) or uses a fixed narrative phrase (constructor, getter, setter, build method). Also catches the <name> method/widget patterns. These filler comments add no information beyond the identifier name — a common AI-agent output pattern. Severity: info. Generated files are skipped automatically.
// MyWidget
class MyWidget extends StatelessWidget {
// build
@override
Widget build(BuildContext context) => const SizedBox();
// constructor
const MyWidget();
}class MyWidget extends StatelessWidget {
// Renders as a zero-size placeholder during the loading phase.
@override
Widget build(BuildContext context) => const SizedBox();
const MyWidget();
}Going deeper
TheDeveloper Guidecovers the full CLI reference, configuration options (rule toggles, path suppression, inline // loam-ignore: directives), and the complete baseline onboarding sequence — including how to integrate loam gate into GitHub Actions.
Automatic suppression for code-generator inputs is described in detail under "Automatic codegen-input suppression" in the guide — the three detection signals (base-type registry, annotation registry, structural fallback) work without any loam.yaml configuration.
Planned
These rules are on the post-MVP roadmap. Each is a separate plugin behind the same Rule interface — the pipeline will not change when they ship.
Architecture boundaries
Auto-detects your layer layout (core/, features/, services/, …) and flags imports that cross forbidden boundaries. Zero-config: the derived rule set is written out transparently and can be frozen or refined.
// features/profile/profile_page.dart
// ↓ features layer reaching into services internals
import '../../services/db/internal_schema.dart';// services exposes a public API instead:
// services/user_repository.dart (public interface)
// features/profile/profile_page.dart
import '../../services/user_repository.dart';Anti-AI-slop
Detects AI-agent quality shortcuts: dead guard clauses and hallucinated abstractions. (Groundless // ignore: directives are already live as slop-unjustified-ignore; empty and comment-only catch blocks are already live as slop-empty-catch; narrative filler comments are already live as slop-narrative-comment.) Deterministic AST patterns run first; an optional LLM layer handles intent-level slop via a verdict cache — the same code always produces the same verdict, zero token cost on repeats.
// narrative filler comment
/// This method processes the data by iterating
/// over all items and applying the transformation.
List<String> process(List<String> items) =>
items.map((i) => i.toUpperCase()).toList();// doc comment adds value, not noise
/// Returns [items] uppercased.
List<String> process(List<String> items) =>
items.map((i) => i.toUpperCase()).toList();Going deeper
TheDeveloper Guidecovers the full CLI reference, output formats, configuration, and worked examples — including how to integrate loam gateinto GitHub Actions.