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 both an enclosing Semantics(label: …) ancestor and a non-empty label: … / semanticLabel: … / tooltip: … argument on the call site itself — WCAG 4.1.2 Name, Role, Value. The call-site property check covers widgets that encapsulate their own accessibility (self-wrapping in Semantics(...), or delegating the value to an already-excluded Flutter widget) without needing to inspect the target class’s definition. 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: …) and the three recognised call-site properties. |
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 (together with project-wide finding load) into a composite 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 that add no information beyond what the code already shows. Declaration-adjacent: immediately before a declaration, normalised text restates the declaration name (e.g. // build before void build()) or belongs to a fixed restatement list (“constructor”, “getter”, “setter”, “build method”) or the <name> method/widget patterns. Context-free (anywhere in the file): decorative banner/divider comments (// ====, // ****** SECTION ******), bare structural category labels (“main logic”, “helper function”, “error handling”, …), emoji-only decoration, redundant end-of-block markers (// end if, // End processOrder), and vague TODOs with no concrete action, condition, or reference (a TODO with real substance, e.g. // TODO: handle null case when userId is missing (see #123), is never flagged). 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, plain hyphen dividers (// ---------------) — a common, legitimate section-separator convention, not AI-slop — and // comments with informative text that does not match any of the fixed categories above (no fuzzy heuristic quality scoring). |
slop-workflow-narration-comment | Sequential “workflow narration”: two or more // Step 1: …, // Step 2: …, // First, …, // Next, …, // Then, …, // Finally, … comments, each immediately preceding a statement in the same function/method/constructor body, that together narrate the body step-by-step even though the statements are already self-explanatory. A single such comment on its own is not flagged — only a sequence (2+) is. Severity: info. Category: slop. Generated files are skipped automatically. What it deliberately does NOT catch: /// Dart-doc comments, block comments, a lone workflow-style comment with no sequence, and comments not immediately adjacent to the statement they narrate. |
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.
Recommendation
A Recommendation is a curated, agent-addressed suggestion for how to avoid
an entire class of Finding going forward — distinct from a Finding, which
describes one concrete occurrence at a file/line. After a run with Findings,
loam.dev appends exactly one Recommendation per distinct fired ruleId
(deduplicated, even if that rule fired dozens of times), sorted
lexicographically for determinism. The block is not shown on a clean run (no
Findings ⇒ no empty-noise block).
Recommendations are curated, not LLM-generated — a hand-written,
versioned guidance corpus (guidance@ver, currently guidance@v2) keyed by
ruleId, with no LLM call in the scan/gate path (Invariant 2). The text is
addressed to the AI agent consuming loam.dev’s output, and explicitly asks the
agent to propose the relevant recommendations to its user for their
persistent instructions (e.g. CLAUDE.md) — loam.dev itself never writes to
user instructions.
In human/markdown/html output the block renders as readable prose with
the agent instruction spelled out; in json it is a structured
recommendations array of { "ruleId": "...", "guidance": "..." } entries,
omitted entirely (not emitted empty) when there are no recommendations.
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 options
--format <format> Output format (default: human).
--no-progress Suppress the live progress bar.
Available formats: human · sarif · json · markdown · html.
Live progress. scan, gate, baseline, slop, a11y, and health all
show the same live loading/analysis progress bar in an interactive terminal.
It is automatically disabled outside a TTY and under CI (non-empty CI env
var) — never interferes with piped or machine-readable output. Silence it
explicitly with --no-progress or the LOAM_NO_PROGRESS environment
variable; progress never appears in structured output (sarif/json/
markdown/html) regardless.
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
Composite Health-Score (0–100) and Grade (A–F) combining finding load (severity-weighted, size-normalised) and complexity hotspots (weighed absolutely, not diluted by function count), plus a descending Hotspot table (file:line, symbol, cyclomatic, cognitive). The terminal output breaks the score down into its Findings and Complexity contributions.
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: four —
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), slop-narrative-comment (// comments that
restate the declaration name or use a fixed narrative phrase, plus banner
comments, empty category labels, emoji-only decoration, end-of-block markers,
and vague TODOs), and slop-workflow-narration-comment (sequential Step 1:/
First,/Next,/Then,/Finally, comments narrating a function body
step-by-step). 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 and no label, semanticLabel, or tooltip argument with a
non-empty value on the call site itself (the latter covers custom widgets
that encapsulate their own accessibility, e.g. by wrapping themselves in
Semantics(label: …) internally or delegating the value to an
already-excluded Flutter widget). 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.
Fix-Prompt (html report). The HTML report lets you select Findings and
copy a versioned Fix-Prompt (prompt@v3) for an AI coding agent. The prompt
names the analysed project via a stable, checkout-independent identifier (no
absolute path), scopes the agent to that project’s own source tree, and
structures its instructions along four principles: think first (state
assumptions, ask instead of guessing), simplicity first (smallest fix, no
speculative abstraction), surgical changes (touch only what the Finding
requires, preserve existing style), and goal-driven (one verifiable success
criterion per Finding). The Dart template and the report’s inline JS build
byte-identical prompts (single source of truth, Invariant 5).
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