ala_lint: putting the ALA Checklist in CI

September 16, 2026 · ALA · ala_lint · tooling · Elixir

Every other post here leans on a tool called ala_lint. This one is about the tool itself: what it checks, how to run it, how to read what it says, and how it connects to the checklist notation from the thermometer post and the full checklist.

One framing up front, because it sets expectations. ala_lint is a gross filter, not a verdict. It rules out clearly-bad structure cheaply and in CI. It cannot tell you an abstraction is the right one, or that a design reads well. The full checklist is a human instrument; the linter is the part of it you can run on every commit. A green run is necessary, not sufficient.

Install

It will be on Hex shortly. Until then, and afterward if you like, add it as a dev-only dependency that never ships in your release:

# mix.exs
defp deps do
  [
    {:ala_lint, "~> 0.1", only: [:dev, :test], runtime: false}
    # or, from a checkout:  {:ala_lint, path: "../ala_lint_elixir", only: [:dev, :test], runtime: false}
  ]
end

It analyzes source by parsing, so it does not compile your project and needs none of your dependencies. You can also point it at a directory with no setup at all:

AlaLint.analyze("../some_project/lib") |> AlaLint.Report.to_text() |> IO.puts()

Run it

mix ala.lint                      # score lib/
mix ala.lint lib/app lib/app_web  # several roots; everything outside them is excluded
mix ala.lint --min-score 75       # exit non-zero below 75, the CI gate
mix ala.lint --help               # usage; unknown flags warn rather than being ignored

The options worth knowing:

Durable, per-check settings do not belong on the command line. They go in an optional .ala_lint.exs map at the project root, so the CLI stays small as the check list grows:

# .ala_lint.exs
%{
  layers_module: MyApp.AlaLayers,
  min_score: 85,
  checks: %{
    r7: :off,                       # off | advisory | scored
    height: [level: :scored, max: 4],
    public_surface: [max: 15]
  }
}

A check’s setting is a level (:off, :advisory, :scored) and an optional threshold, one uniform mechanism for turning a check off, changing its level, or retuning it. And there is a small honesty guard: a run that disabled or downgraded any check says so in its parameter echo, so a green score never quietly hides which rules it skipped.

Reading the output

A run prints a header, two headline scores, the findings, and the parameters it used. Trimmed:

── ALA Checklist (R1–R11) ──────────────────────────────────────────────
modules: 24   functions: 399   LOC: 6183
abstraction height: 9 call-levels (function graph)  ⚠ exceeds max 5
layer coverage: 303/399 functions assigned (76%)
  unassigned (worklist): App.Application, AppWeb.Telemetry, … +19 more

Degree of function compliance: 92/100  (grade A)
Count of compliant functions: 395 / 399  (99%)

Findings (scored, most severe first):
  [r1] lib/app/features/cart.ex:18  Cart.peek/1 [feature] → Wish.look/1 [feature]: cross-peer edge …

Advisory (reported, NOT scored — R7, height, pass-through):
  [passthrough] lib/app/x.ex:9  X.forward/1 is a pass-through (1 caller, 1 callee → …)

The two scores answer different questions, and you want both:

Grades are A ≥ 90, B ≥ 75, C ≥ 60, D ≥ 40, else F. Every finding carries a file:line, so a flag is one click from the code.

Two more lines in the header earn their place. Abstraction height is the longest chain of knowledge dependencies in the function call graph (calls within the application layer count as one altitude), and a deep stack is a proliferation signal worth a look. Layer coverage is the fraction of functions assigned to a layer, with the unassigned ones listed. On a codebase moving toward ALA, that worklist is the point: it tells you what is not yet organised.

The rules, and their honesty

The linter is explicit about which checks are exact and which are heuristic proxies for a human judgement:

R7, R11, module size, abstraction height, pass-through, and a reference-level R1 signal are advisory: reported, never scored by default, because these are prompts for a reader (Spray never required reuse; a source-encoded app layer branches). --strict promotes the obtainable ones (R7, module-size, height, pass-through, R1-reference); --super-strict additionally scores the aspirational ones (R11, public-surface, and R10-aggregate); or promote any single check with --enforce. R9 (ports carry paradigm-typed data; no abstraction names its own I/O) is checked only in part, through R1 and the reference-level signal. And R8 (does the composition read as the requirements?) is not checked at all; it is judgement, and it is where the tool stops and you begin.

Assigning layers

The layer-aware checks (R1 altitude, coverage) need to know which tier each function sits in. You declare that; the tool does not infer it, because inferring layers from the call graph would make “does this edge drop?” trivially true. A layer is matched by a function’s own @ala_layer tag, or by what it uses (a schema is persistence even in a domain namespace), or by module-name pattern or filesystem path:

def layers do
  [
    {:app,     [~r/Web\./],                peer_ok: true,  paths: [~r{/live/}]},
    {:feature, [],                         peer_ok: false, paths: [~r{/features/}]},
    {:domain,  [~r/App\.(Cart|Pricing)$/], peer_ok: true},
    {:platform,[~r/Effects/],              uses: [~r/Ecto\.Schema/, ~r/Ash\.Resource/]}
  ]
end

With no layer map the tool skips coverage and R1-altitude and runs the structural checks that need no layers. That is the on-ramp for a codebase not yet layered.

Tightening the gate: a strict run

Default mode scores the structural rules and leaves the advisory ones as a reading list. The two tighter modes turn those into gates, and it is worth seeing all three side by side across the LiveView variants and one pre-ALA app:

target                 normal   --strict  --super-strict
v30 committed-codegen   89/B     88/B       85/B
v33 flows+purity        91/A     90/A       87/B
v34 manifest-config     93/A     93/A       89/B
v35 composed-inputs     94/A     93/A       90/A
v36 vernacular-core     94/A     94/A       92/A
kbran (layer-blind)     71/C     67/C       66/C

--strict scores the obtainable advisories: pass-through helpers, unearned abstractions (R7), oversized modules, deep stacks. The codegen and flows variants carry a handful of one-in/one-out forwarding functions that strict counts, which is why they sit a couple points below the vernacular core. V36 is the outlier: --strict changes its score by nothing (Δ0), because it has no pass-throughs and no oversized modules. That is the strongest single-number argument for the vernacular-core design.

--super-strict adds the aspirational-purity checks: R11 (no logic at the top), a public-surface check, and the shared-domain-aggregate reading of R10. Everything drops a little here, even V36 (94 → 92), because its composition, like every real LiveView’s, branches to sequence cross-feature follow-ups. That is exactly why R11 lives in super-strict and not strict: it is impractical to zero out in a working app, and even Spray’s ideal is “the top reads as the requirements,” not “zero if-statements.” Super-strict is where you go to chase the last of that purity; strict is the CI-worthy gate.

An accuracy note underneath all of it: these counts exclude false positives a naive call-graph would report. HEEx function components (defp tabs(assigns) rendered <.tabs/>) live inside a ~H string the AST never sees; &capture/1 references and quote-injected defs have call sites the graph misses; ~p"/route" strings are compile-checked, not silent contracts; and a call piped into another call is a transform, not a rename. ala_lint accounts for each, so the numbers above reflect design, not tooling artifacts. (kbran, run layer-blind, still shows ~36 pass-throughs and 49 R7 flags, the honest shape of code not built to ALA.)

Encoding your code into the checklist notation

The thermometer post introduced a small notation for writing an ALA design down: f [tag] functions, pN wires, $ hidden state, qN silent contracts, an optional @name-Lidx level, and {app-literal?}/{app-literal}/{intrinsic-literal} marks for configuration literals. ala_lint can produce a first draft of that encoding for a whole codebase.

mix ala.encode                    # write a parallel .ala.md tree of lib/ to ala_encoding/
mix ala.encode --layers-module MyApp.AlaLayers   # with the @levels filled in
mix ala.lint.encoding             # check a completed encoding tree

mix ala.encode walks your code and writes one .ala.md file per source file, mirroring the tree. It fills what a machine can: the fully-qualified function names, the dependency edges annotated with whether each drops, the @name-Lidx levels from your layer map, and a {app-literal?} seed on every function that owns a configuration-candidate literal (the value stays opaque; the linter has its file:line). It leaves the things only a human can judge: the semantic [?] tag (what does this code know?), and whether each {app-literal?} is really top-level {app-literal} or {intrinsic-literal} to the implementation.

It also stamps the facts a linter can decide, so the encoding does not fall behind the checks that live in the tool’s call-graph analysis: &entity/&aggregate where a module shares a domain entity with peers (R10), ~> on a pass-through, (branches) on an application-layer function that branches (R11), and (private) on private functions (so public-surface and pass-through scoping stay honest). You keep those marks as written; you only fill in the tags and resolve the literals.

That draft is the intermediate checklist data. A human completes it, and mix ala.lint.encoding then checks the completed tree: edges that do not drop, $ and q marks to confirm, {app-literal} literals still sitting below the composition, the stamped R10/R11/pass-through/public-surface marks, and anything left unresolved. The goal is that a correctly completed encoding re-lints to the same findings the linter reports on your source — R6 and R7 aside (pure judgement, never encodable), plus abstraction height and the R11 app-share aggregate, which the encoding can only approximate because it carries module-level edges rather than the full call graph. It is the bridge between the mechanical scan and the full checklist, and it is where the tool hands the judgement calls back to you with the structure already filled in.

Where it fits

Adopt it one strictness level at a time, because the three flags are a gradual-adoption path, not just settings. Start with the default mix ala.lint --min-score N in CI and a declared layer map so R1 and coverage light up — the Required tier, the structural floor. When that is quiet, turn on --strict and treat its Advisory signals as a reading list, promoting individual checks with --enforce (or a .ala_lint.exs file) as the team agrees they are worth a gate. Reach for --super-strict and its Aspirational purity checks last, on greenfield or mature code, reading each flag as a question rather than a defect. The checklist post walks that same tier-by-tier path across the rules and the encoding together. Use the encoding when you want to walk a design against the checklist by hand with the mechanical parts already done. And remember the framing: the linter clears the floor cheaply so that human attention goes to the things it cannot see, which is most of what makes a design good.

← all posts