loam.dev Developer & Tool Guide
A practical reference for using loam.dev — the Dart/Flutter codebase intelligence CLI — covering core concepts, CLI commands, output formats, and how loam.dev handles code generators automatically.
Naming. The product is loam.dev;
loam(no.dev) is exclusively the CLI command and the pub.dev package name. The capitalised form without.devis anti-vocabulary — always writeloam.devin prose.
Core concepts
Finding
A Finding is a single analysis result:
{ ruleId, severity, location, message, fingerprint }
Every Finding has a stable Fingerprint — a position-robust hash used for
baseline diffing. ruleId identifies which Rule produced it; severity is one of
error, warning, or info.
Vocabulary note: “Issue” refers to project tracker tickets, not analysis results. Always use “Finding” in the context of loam.dev output.
Severity
Three levels, in descending order: error · warning · info.
Rule
A Rule is one analysis unit behind the shared Rule interface. Each capability
(unused public API, slop detection, cycle detection …) is a separate Rule. Adding a
new Rule never changes the analysis pipeline — only the rule list.
Rules are either:
- DeterministicRule — pure AST/element-model analysis. Reproducible, offline, no cost. All currently active rules are deterministic.
- LlmRule (planned) — uses an LLM for
{score, label}via a versioned VerdictCache (sha(code)+prompt@ver). Same code = cache hit = stable verdict = zero token cost. The LLM path is never hit ungached in the gate (Invariant 2).
Currently active rules:
| Rule ID | What it finds |
|---|---|
a11y-form-field-label | Flutter TextField and TextFormField without a visible or accessible label — neither decoration: InputDecoration(labelText: …) nor InputDecoration(hintText: …) (accepted as fallback) nor an enclosing Semantics(label: …) ancestor. WCAG 1.3.1 Info and Relationships / 3.3.2 Labels or Instructions / 4.1.2 Name, Role, Value. Uses 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: out of scope are runtime contrast, focus order, touch-target sizes, and dynamic decoration values. |
a11y-icon-button-label | Flutter IconButton without a tooltip: argument and without an enclosing Semantics(label: …), and GestureDetector/InkWell whose direct child: argument is a pure Icon and that likewise lacks an enclosing Semantics(label: …) — WCAG 4.1.2 Name, Role, Value. Uses the resolved element model; local classes named IconButton are never flagged. AST-only: out of scope are composite icon children, dynamic tooltip values, and custom accessible-name mechanisms. |
a11y-image-label | Flutter Image widgets (Image(), Image.asset, Image.network, Image.file, Image.memory) without semanticLabel or excludeFromSemantics: true — WCAG 1.1.1 Non-text Content. Uses the resolved element model to identify Flutter’s Image, not regex. AST-only: out of scope are runtime contrast, focus order, and touch-target sizes. |
a11y-interactive-semantics | Generic and custom (non-Flutter) interactive widgets that carry an onTap, onPressed, or onLongPress named argument but lack an enclosing Semantics(label: …) ancestor — WCAG 4.1.2 Name, Role, Value. Flutter built-in interactive widgets covered by sibling rules (IconButton, GestureDetector, InkWell, TextField, TextFormField) are excluded. Uses the resolved element model (library URI check); local classes with the same name as excluded Flutter widgets are never skipped. AST-only: out of scope are interactive behaviour via inherited callbacks, custom gesture recognisers that bypass the named parameters, and accessible-name mechanisms other than Semantics(label: …). |
circular-dependencies | Circular import/export chains between first-party lib/ libraries — one finding per strongly connected component, naming all files in the loop. |
code-duplicates | Structurally identical code blocks over the resolved AST (token-normalised, Rabin-Karp clustered); detects Type-1 (exact) and Type-2 (renamed identifiers/literals) duplicates project-wide. One finding per cluster listing all locations, with a stable fingerprint that survives line shifts. |
complexity-hotspots | Cyclomatic and cognitive complexity per executable (function, method, constructor, accessor); flags hotspots above documented, conservative thresholds. Aggregated by loam health into a Health-Score (0–100) and Grade (A–F). |
slop-empty-catch | catch blocks whose body is empty ({}) or contains only comments — both silently swallow exceptions. Severity: warning. Category: slop. Extends the built-in empty_catches lint (literal-empty only) to also flag the comment-only variant that AI agents commonly produce. Conservative allowlist: any rethrow, throw, or logging call in the body suppresses the finding (all produce at least one statement). Generated files are skipped automatically. What it deliberately does NOT catch: bodies with any non-comment statement (rethrow, throw, logging call, or any other executable code). |
slop-unjustified-ignore | // ignore: and // ignore_for_file: directives with no written justification — either inline (text after the lint names, separated by – or similar) or on the immediately preceding comment line. Severity: info. Category: slop. Generated files (.g.dart, .freezed.dart, etc.) are skipped automatically. What it deliberately does NOT catch: directives that have any non-empty text after the lint-name list (inline reason), directives whose preceding line contains any // comment (accepted as a reason), and directives in generated files. |
slop-narrative-comment | // comments immediately before a declaration whose normalised text restates the declaration name (e.g. // build before void build()) or belongs to a fixed restatement list (“constructor”, “getter”, “setter”, “build method”). Also catches the <name> method/widget patterns. Severity: info. Category: slop. Generated files (.g.dart, .freezed.dart, etc.) are skipped automatically. What it deliberately does NOT catch: /// Dart-doc comments (never flagged), block comments, // comments with informative text that does not match the name or fixed list, and comments not immediately adjacent to a declaration (blank line between). |
unused-public-exports | Public API members (classes, methods, getters/setters, fields, enums, typedefs) with no references anywhere in the project — on the resolved element model, not regex. |
The remaining rules in the planned target surface (boundary violations, more AI-slop rules, hardcoded secrets) are still to come (🚧).
Baseline
A Baseline is a frozen snapshot of accepted Findings written to baseline.json.
The Gate uses it as a reference: only new Findings (not in the baseline) count
against the gate decision. A Baseline is the bridge between a full audit and
ongoing CI hygiene.
Gate
The Gate is loam.dev’s pass/fail decision with an exit code, designed for CI.
Two modes:
| Mode | Behaviour | When to use |
|---|---|---|
| Ratchet (default) | Only NEW Findings fail. Kept (frozen) and fixed Findings are transparent. The score can only improve. | All projects with existing code (established repos). |
Absolute (--absolute) | All current Findings are evaluated against a fixed threshold (default 0). Baseline is ignored. | Greenfield projects, or pipelines requiring zero findings. |
Ratchet is the default. This means an existing codebase never goes red on day one — you baseline the current state and only new regressions fail CI.
Reporter
A Reporter converts Findings into output. It is a pure renderer: it has no influence on the gate decision or exit code (Invariant 4).
Available formats: see Output formats below.
Reproducibility
loam.dev is deterministic by design:
Same code + same
ruleset@ver⇒ identical Findings.
The ruleset@ver is stored in baseline.json and in SARIF/JSON output alongside
the tool version. When the ruleset version changes (a rule is added or its logic is
updated), the gate warns and suggests a baseline refresh. There is no silent
rule-version drift.
For the LLM layer (planned): sha(code)+prompt@ver keys the VerdictCache, so
even the LLM path is deterministic per code snapshot.
Configuration & discovery
loam is Zero-Config: without a loam.yaml it works out of the box. To customise,
scaffold one with loam init (writes a fully-commented loam.yaml to the project
root; it never overwrites an existing file).
Discovery. loam looks for loam.yaml by walking up from the analysed
project root to — and including — the enclosing git repository root (the
directory holding .git), exactly like Dart’s own analysis_options.yaml. So a
config at your repo root applies even when a sub-package is analysed. The walk is
deliberately bounded at the git root: a loam.yaml above the repository (e.g.
in $HOME) is never picked up, which keeps discovery repo-relative and
reproducible across machines and CI. With no git root, only <projectRoot>/loam.yaml
is read.
Layering (monorepos). Every loam.yaml on the way is merged farthest→nearest,
so the repo root provides shared defaults and a nearer (per-package) config builds
on top of it. Merge rules — nearer wins:
| Key | Merge behaviour |
|---|---|
rules | merged per ruleId; the nearer value overrides |
ignore | concatenated (farther first), de-duplicated, order preserved |
source_dirs | scalar override; a layer that omits it inherits the farther value |
update_check, a11y | scalar override; omitted → inherit |
ignore globs are always matched relative to the analysed project root,
regardless of which layer declared them — a repo-root ignore: ["**/*.g.dart"]
therefore applies relative to each analysed package.
CLI commands
Install via Homebrew (recommended on Apple Silicon macOS / Linux):
brew install silvio-l/loam/loam
Or via pub.dev (all platforms):
dart pub global activate loam
Global option
--format <format> Output format (default: human).
Available formats: human · sarif · json · markdown · html.
loam scan
Full audit: runs all active rules across the whole project, baseline-independent. Use this to see every Finding regardless of what is in the baseline.
loam scan # current directory
loam scan -p /path/to/project # explicit project root
loam scan --format json # machine-readable output
Exit code 1 when any Findings are present; 0 when clean.
This is the Vollaudit command — start here to assess a new project before freezing a baseline.
loam baseline
Show, write, or update the baseline (baseline.json).
loam baseline # show current baseline findings
loam baseline --write # freeze current findings as baseline
loam baseline --update # refresh an existing baseline
Baseline onboarding flow for an existing project:
loam scan— see everything- Fix or accept what you can
loam baseline --write— freeze the accepted stateloam gatein CI from now on
loam gate
CI gate: evaluates current Findings against the baseline.
loam gate # ratchet mode (default): only new findings fail
loam gate --absolute # absolute mode: all findings evaluated, threshold 0
loam gate --format sarif # SARIF output (CI code-scanning)
Exit code 1 when the gate fails; 0 when it passes.
Prints a terse summary line:
loam gate: N neu, M eingefroren, K gefixt — grün.
loam health
Cyclomatic/cognitive complexity distribution view — shows Health-Score (0–100), Grade (A–F), and a descending Hotspot table (file:line, symbol, cyclomatic, cognitive).
loam health # analyse current directory
loam health /path/to/project # positional project root
loam health -p /path/to/proj # --project-root override
Exit code: always 0 on a successful run (even with a low score — gating stays
loam gate). Exit 64 on a usage error (e.g. two positional arguments).
The health score is a diagnostic view: it always measures, regardless of whether
complexity-hotspots is disabled in loam.yaml. The codegen exclusion (generated
files excluded by the collector) stays active.
loam slop
AI-slop audit: runs slop-category rules only across the whole project.
loam slop # analyse current directory
loam slop /path/to/project # positional project root
loam slop -p /path/to/proj # --project-root override
loam slop --format json # machine-readable output
Exit code 1 when any slop Findings are present; 0 when clean.
The slop category is run-isolated from drift and accessibility categories —
loam slop only ever executes slop-category rules.
loam scan includes slop rules default-on. Currently active slop rules: three —
slop-empty-catch (empty and comment-only catch bodies that silently swallow
exceptions), slop-unjustified-ignore (// ignore: and // ignore_for_file:
without a written justification), and slop-narrative-comment (// comments
that restate the declaration name or use a fixed narrative phrase). Further slop
rules (dead guards, …) are planned.
loam a11y
Accessibility audit: runs accessibility-category rules across the whole project.
loam a11y # analyse current directory
loam a11y /path/to/project # positional project root
loam a11y -p /path/to/proj # --project-root override
loam a11y --format json # machine-readable output
Exit code 1 when any accessibility Findings are present; 0 when clean.
Accessibility rules target WCAG-relevant Flutter-widget patterns (missing semantic
labels, missing tooltips on icon buttons, missing labels on form fields, …). The
a11y category is run-isolated from drift and slop categories — loam a11y only
ever executes accessibility-category rules; drift and slop rules are not touched.
loam scan includes the accessibility category default-on. To exclude it:
loam scan --no-a11y # skip all accessibility-category rules
Or add a11y: false to loam.yaml for a repo-wide default. Precedence:
--no-a11y (CLI) > a11y: false (loam.yaml) > default (on).
Four accessibility rules are currently active: a11y-form-field-label (WCAG
1.3.1 / 3.3.2 / 4.1.2), which flags Flutter TextField and TextFormField
widgets without a decoration: InputDecoration(labelText: …) (or hintText
fallback) and without an enclosing Semantics(label: …) ancestor;
a11y-image-label (WCAG 1.1.1 Non-text Content), which flags Flutter Image
widgets without semanticLabel or excludeFromSemantics: true;
a11y-icon-button-label (WCAG 4.1.2 Name, Role, Value), which flags
IconButton without tooltip and GestureDetector/InkWell with a pure
Icon child when no Semantics(label: …) ancestor provides an accessible
name; and a11y-interactive-semantics (WCAG 4.1.2 Name, Role, Value), which
flags generic and custom (non-Flutter) widgets that carry an onTap,
onPressed, or onLongPress callback but have no Semantics(label: …)
ancestor. See the “Currently active rules” table above.
loam init
Scaffold a loam.yaml configuration in the current project.
loam init # writes loam.yaml in the current directory
loam init -p /path/to/proj # specify a different project root
If loam.yaml already exists, the command refuses to overwrite it and exits with
code 1 — no silent data loss. Delete or edit the file manually to replace it.
The generated loam.yaml includes commented examples for the rules:, ignore:
and source_dirs: sections. The file is valid and loadable as-is (all examples are
YAML comments, so no rule toggles are active until you uncomment them).
source_dirs: — production-source scope for the complexity scan
complexity-hotspots and loam health measure the universal property of
function complexity, so they need to know which directories hold hand-written,
shipping code. By default they measure lib/ and bin/. Override per project:
source_dirs:
- lib
- bin
# - tool # widen to include dev scripts
# - test # widen to include tests (usually noisy — intentionally complex)
Rules of the road:
- Generated files are always excluded (
*.g.dart, gen-l10n output, …), regardless ofsource_dirs. test/,example/andtool/are off by default — test matrices and demo code carry intentionally high (or low) complexity that would only add noise.circular-dependenciesandunused-public-exportsare inherently alib/concept (entrypoints can’t sit in an import cycle; “public API” meanslib/) and are not affected bysource_dirs.- For finer control, suppress individual findings by path with
ignore:globs or inline// loam-ignore: <ruleId>directives.
loam fix (coming soon)
Apply mechanical fixes for Findings that have a safe auto-fix. Not yet implemented.
Output formats
All formats are selected with the global --format flag and work with every command
that produces Finding output.
| Format | Status | Use case |
|---|---|---|
human | Available | Default. Human-readable terminal output with colour (when TTY). |
sarif | Available | SARIF 2.1 JSON for CI code-scanning tools (GitHub, GitLab, …). |
json | Available | Machine-readable JSON for agent/tooling integration. |
markdown | Available | Markdown report for PR comments, docs embedding, LLM pipelines. |
html | Available | Self-contained HTML report (loam-report.html by default; override with --output), opened automatically in the browser on interactive runs. --no-open skips it; auto-off when piped or under CI. |
The Reporter is a pure renderer — format choice never affects exit codes or gate decisions.
Automatic codegen-input suppression
Why your generated-code inputs are not reported
loam.dev does not report public members of code-generator input classes as unused. This is automatic — no comments, no configuration required.
Why? Code generators like Drift, freezed, Riverpod, and json_serializable read
your class at build time and produce a companion *.g.dart or *.freezed.dart
file. The generated file uses every member you declared, but the Dart element model
sees no static reference from your hand-written code to those members. Without
suppression, loam.dev would report every column in a Drift Table class as unused —
a false positive.
How detection works
Detection is semantic (element model, not source text). Classification uses “first match wins” over three signals, in this order:
1. Base-type registry (base_type:<name>)
If the class (or any supertype in its chain) is one of the known Drift base types, it is classified as a codegen input:
| Matched type | Example |
|---|---|
Table | Drift table definitions |
DataClass | Drift data classes |
View | Drift view definitions |
The check walks the full supertype chain via the element model — it is not a string match on source text (Invariant 1).
2. Annotation registry (annotation:<name>)
If the class carries one of these annotations:
| Annotation | Ecosystem |
|---|---|
@DriftDatabase | Drift |
@DataClassName | Drift |
@riverpod | Riverpod (lowercase constant) |
@Riverpod | Riverpod (class annotation) |
@freezed | freezed |
@JsonSerializable | json_serializable |
3. Structural fallback (fallback:part_generated)
If both conditions hold:
- The library declares a
part '*.g.dart'orpart '*.freezed.dart'directive. - The class itself binds a generated counterpart — it
extends _$Xorwith _$X(the_$-prefixed convention used by freezed and Riverpod class-based notifiers).
The part directive alone is deliberately not sufficient. Plain hand-written
classes that merely share a file with a generated notifier must remain candidates
(no over-suppression). The class must also bind the _$-symbol directly.
What stays reported
Plain classes, utilities, and any class that matches none of the three signals above
remain candidates for the unused-public-exports rule. The suppression is targeted —
it only applies where the generator actually consumes the members.
User-driven suppression
Three complementary mechanisms let you suppress Findings intentionally.
All three act before the baseline and gate — suppressed Findings are invisible
to the gate and do not appear in output or baseline.json.
1. Rule toggles (loam.yaml — rules: map)
Disable a rule project-wide in loam.yaml:
rules:
unused-public-exports: false # rule is disabled; its findings are never produced
- A disabled rule is not run at all (no findings, no gate cost).
- Disabling a rule changes the
rulesetVersion(the gate warns and suggests a baseline refresh — Invariant 5). - An unknown
ruleIdraises a clear error on startup (ConfigLoadException) — no silent typo pass-through.
2. Path suppression (loam.yaml — ignore: glob list)
Exclude whole files or directories from the audit with project-relative glob patterns:
ignore:
- "lib/generated/**" # all files under lib/generated/
- "**/*.g.dart" # every .g.dart file anywhere in the project
- Matched files are removed from the audit entirely — no findings are produced for them, regardless of which rules are active.
- Path suppression does not change the
rulesetVersion(it does not alter the rule set — only the scope of analysed files). - Patterns are matched against project-relative POSIX paths (reproducible across platforms, Invariant 5).
3. Inline suppression (// loam-ignore: directive)
Suppress a single Finding for one specific rule at one specific location by
placing a // loam-ignore: comment on the same line as the flagged code or
on the immediately preceding line:
// loam-ignore: unused-public-exports – exported via barrel file, not direct ref
class MyPublicApi { … }
class AnotherClass { … } // loam-ignore: unused-public-exports – plugin entry point
Format: // loam-ignore: <ruleId> – <reason>
<ruleId>is the rule identifier (e.g.unused-public-exports).- The reason is mandatory — directives without a reason are silently ignored (Grund-Pflicht).
- A separator between rule ID and reason is conventional (
–,-, or a space) but flexible; what matters is that non-empty text follows the rule ID. - Only the named rule at the named location is suppressed; other findings of the same rule at different locations are not affected.
- This mechanism is distinct from automatic codegen-input suppression — the inline directive is user-authored and intentional, not derived from the element model.
Scaffold via loam init
loam init writes a commented loam.yaml scaffold that illustrates all three
mechanisms. If loam.yaml already exists, the command refuses to overwrite it
(exit code 1) — no silent data loss.
Links
- Root README — install, quick start, roadmap
- getloam.dev — website