loam.dev
v0.1.13 · previewDE

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-exportslive

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.

slop (bad)
// 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 }
clean
// 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-dependencieslive

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.

slop (bad)
// lib/a.dart
import 'b.dart';

// lib/b.dart
import 'a.dart'; // ← cycle: a → b → a
clean
// 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.dart
code-duplicateslive

Code 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.

slop (bad)
// 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}';
clean
// 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 symmetry
complexity-hotspotslive

Complexity 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.

slop (bad)
// 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 */ }
      }
    }
  }
}
clean
// 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 { … }
a11y-form-field-labellive

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.

slop (bad)
// no decoration at all — screen reader has no label to announce
TextField()

// InputDecoration present but no labelText or hintText — still missing
TextField(
  decoration: InputDecoration(),
)
clean
// 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(),
)
a11y-image-labellive

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.

slop (bad)
// 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
clean
// 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)
a11y-icon-button-labellive

Icon button without accessible name

Flags Flutter IconButton widgets that have no tooltip and are not wrapped in a Semantics(label: …) ancestor, and GestureDetector/InkWell widgets whose direct child is a pure Icon widget with the same omission. WCAG 4.1.2 (Name, Role, Value) requires every interactive control to have a programmatically determinable name. Analysis runs on the resolved element model — local classes named IconButton are never flagged. AST-only: composite icon children and dynamic tooltip values are out of scope.

slop (bad)
// no tooltip — screen reader announces nothing
IconButton(
  icon: Icon(Icons.close),
  onPressed: () => Navigator.pop(context),
)

// GestureDetector with bare Icon child — same problem
GestureDetector(
  child: Icon(Icons.delete),
  onTap: () => deleteItem(),
)
clean
// tooltip provides the accessible name
IconButton(
  icon: Icon(Icons.close),
  tooltip: 'Close',
  onPressed: () => Navigator.pop(context),
)

// or wrap with Semantics for GestureDetector/InkWell
Semantics(
  label: 'Delete item',
  child: GestureDetector(
    child: Icon(Icons.delete),
    onTap: () => deleteItem(),
  ),
)
a11y-interactive-semanticslive

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: …).

slop (bad)
// custom tappable card — screen reader has no label
CustomCard(
  onTap: () => showDetails(item),
)

// custom button with onPressed — also unnamed
ActionChip(
  onPressed: () => doAction(),
)
clean
// 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(),
)
slop-empty-catchlive

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.

slop (bad)
// 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
}
clean
// 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);
}
slop-unjustified-ignorelive

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.

slop (bad)
// 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
clean
// 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_print
slop-narrative-commentlive

Narrative 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.

slop (bad)
// MyWidget
class MyWidget extends StatelessWidget {
  // build
  @override
  Widget build(BuildContext context) => const SizedBox();

  // constructor
  const MyWidget();
}
clean
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-boundariesplanned

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.

slop (bad)
// features/profile/profile_page.dart
// ↓ features layer reaching into services internals
import '../../services/db/internal_schema.dart';
clean
// services exposes a public API instead:
// services/user_repository.dart (public interface)
// features/profile/profile_page.dart
import '../../services/user_repository.dart';
anti-ai-slopplanned

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.

slop (bad)
// 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();
clean
// 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.