AlaLint (ala_lint_elixir)

A static-analysis linter that scores an Elixir codebase against the ALA Checklist (R1–R11): coupling & layering, requirements-locus, state-as-a-wire, cross-boundary contracts, nameability, and abstraction minimality. It walks lib/**/*.ex, reports each violation with a location, and computes an overall design-health score.

This is the Elixir implementation of the ALA Checklist and encoding. The checklist itself is language-agnostic and lives in its own repository: modellurgist/ala_checklist. Example designs the linter is applied to live in modellurgist/ala_variants_elixir.

It analyzes source by parsing (Code.string_to_quoted); it does not compile your project, so it runs on any codebase without its deps.

An independent, unofficial implementation based on John Spray's Abstraction Layered Architecture. Not affiliated with or endorsed by the author.

Install (local dev dependency)

# mix.exs
defp deps do
[
{:ala_lint, path: "../ala_lint_elixir", only: [:dev, :test], runtime: false}
# or, once published: {:ala_lint, "~> 0.1", only: [:dev, :test], runtime: false}
]
end
mix ala.lint # score lib/
mix ala.lint lib/my_app # a subtree
mix ala.lint lib/app lib/app_web # several roots — everything outside them is excluded
mix ala.lint --limit 100 # show more findings
mix ala.lint --layers-module MyApp.AlaLayers # layer-aware R1/R3 + coverage
mix ala.lint --min-score 75 # exit 1 if below 75 (CI gate)
mix ala.lint --strict # score the obtainable advisory checks too
mix ala.lint --list-checks # every check, its tier, and its threshold
mix ala.lint --enforce r7 # promote one advisory check to scored
mix ala.lint --disable r3 # turn one check off (and say so in the output)
mix ala.lint --set height.max=4 # retune a threshold (height/module_size/public_surface/app_share/min_score)
mix ala.lint --help # usage; unknown flags warn instead of being ignored
mix ala.encode # write an ALA-notation draft of lib/ to ala_encoding/
mix ala.encode --layers-module MyApp.AlaLayers # with [tag]s filled in
mix ala.lint.encoding # lint a completed encoding tree (R1/R3/R4/R5/R10/R11 + advisories)

Every task also has a real --help, and mix help ala.lint prints the full task docs. Unrecognised options warn (ignoring unknown option(s): …) rather than being silently dropped.

On "config" here. A config module (--config-module) or a config layer (config: true in a layer spec) names a place where application literals — the product-specific constants R3 wants at the composition — are allowed to live. It is not about the value's nature (that is the application-literal / intrinsic-literal distinction); it marks the composition, or a manifest standing in for it. --config-module is the fallback for a codebase with no layer map and is ignored once one is given; with a layer map, R3 uses the config: true layer (defaulting to the top).

Configuring checks (.ala_lint.exs)

The CLI carries the common path (paths, tiers, --min-score). Durable, per-check settings live in an optional .ala_lint.exs map at the project root, so the command line stays small as the check list grows:

# .ala_lint.exs
%{
layers_module: MyApp.AlaLayers,
min_score: 85,
config_modules: ["MyApp.Manifest"],
exclude: [~r{/components/}],
checks: %{
r7: :off, # off | advisory | scored
r3: :advisory, # downgrade a scored (required) rule to reported
height: [level: :scored, max: 4],
public_surface: [max: 15],
module_size: [max: 400]
}
}

A check's setting is a level (:off | :advisory | :scored) and/or a threshold (max:). One mechanism covers "turn it off", "change its level", and "retune it", for any current or future check. mix ala.lint --list-checks prints every check, its tier, and its threshold flag.

For a single run the generic flags override the file: --enforce CHECK promotes one advisory check, --disable CHECK turns one off, --set KEY=VALUE retunes a threshold (height.max, module_size.max, public_surface.max, app_share.max, min_score). 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.

A layers module is any module exporting layers/0 that returns a layer spec (see AlaLint.Layers). Declare it once with config :ala_lint, layers_module: MyApp.AlaLayers and both ala.lint and ala.encode pick it up with no flag. ala.encode writes a parallel .ala.md tree a human completes; ala.lint.encoding then checks that tree. It scores what the notation carries: R1 edge altitude, R4 $, R5 q, R3 via {app-literal?}/{app-literal}/{intrinsic-literal} literal marks, and — from marks the encoder stamps rather than asks a human to judge — R10 (&entity/ &aggregate), R11 ((branches) on an app-layer function), pass-throughs (~>), and public surface (via (private)). R6/R7 stay unencodable; height and the R11 app-share aggregate are reproduced only approximately (the encoding carries module-level edges, not the full call graph). The encoder seeds {app-literal?} on each function owning a configuration-candidate literal (value opaque; the source scan has its file:line); a reviewer resolves it to {app-literal} (an application literal — hoist if it isn't at the top) or {intrinsic-literal} (intrinsic to the abstraction, kept local). The aim is that a correctly completed encoding re-lints to the same findings the source lint gives.

Assigning functions to layers

Each layer declares matchers; a function is assigned most-specific-wins: its own @ala_layer :name attribute > a uses: content match (the module's use macros — e.g. a schema is persistence even in a domain namespace) > the first layer whose module-name pattern or filesystem paths: glob matches > unassigned. Layers are a design decision you declare, never something inferred from the call graph (inferring them would make R1 tautological). You can also pass multiple include roots to restrict analysis to your real code (AlaLint.analyze(["lib/app", "lib/app_web"])), excluding boilerplate elsewhere.

def layers do
[
{:app, [~r/Web\./], peer_ok: true, paths: [~r{/live/}]},
{:feature, [], peer_ok: false, paths: [~r{/features/}], unit: ~r/(App\.\w+)/},
{:domain, [~r/App\.(Cart|Pricing)$/], peer_ok: true},
{:platform,[~r/Effects/, ~r/Foundation/]}
]
end

The report then gives three things a migration cares about: layer coverage (% of functions assigned, with the unassigned worklist), validity errors (a @ala_layer tag naming an undeclared layer), and R1 (do the assigned function→function edges drop?). With no layer map, coverage is skipped and the structural checks that need no layers still run — the on-ramp for a codebase not yet organised into layers.

Or as a library, without adding a dep to the target (point it at any path):

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

The rules (and their precision — this matters)

The checklist mixes exact structural facts with heuristic proxies for judgement calls. AlaLint is honest about which is which:

rule what it flags precision
R1 peer coupling with a layer map: upward and cross-peer edges on the function call graph (an edge that doesn't drop). Without one: module dependency cycles exact
R1-ref template coupling advisory. Peer/upward edges visible only in the module alias graph, not as a call — usually a cross-feature call inside a ~H template (invisible to the AST). Reported to verify advisory
R2 shared mutable state :ets / :persistent_term / Agent usage — a shared-state channel to confirm isn't a peer back-channel advisory
R3 baked calibration "magic" numeric literals inside modules (hoist them to config/the composition) heuristic
R4 hidden state the process dictionary (Process.put/get) — hidden state that should be a threaded value exact
R5 duplicated contracts the same identifier-like string ("item-removed", no whitespace, len 3–40) in ≥2 modules. Strings inside ~p/~H/~L sigils are skipped — a ~p"/x" verified route is compile-checked, not a silent contract exact
R6 nameability meaningless function/module names, and functions that just wrap a primitive heuristic
R7 unearned abstractions dead private functions, and trivial single-use one-liners with meaningless names. HEEx components (invoked as <.name/> in ~H/.heex) and functions defined inside a quote block are recognised as called and never reported dead heuristic, advisory
R10 shared entity a feature-tier struct read by ≥2 peer features — share an identity key, keep data private. Structs in a peer_ok (shareable) layer are exempt heuristic
R10-aggregate shared aggregate a struct in a shareable layer read by ≥2 features — a legitimate domain abstraction, or Clean's shared-Entity coupling? Only runs under --strict/--super-strict strict-only, advisory
R11 composition-only top layer The application layer is a large share of the code (--max-app-share), or a top-layer function branches (all clauses checked). Aspirational purity: reported by default, scored only under --super-strict advisory / super-strict
module size advisory. A module over --max-module-loc (default 500) — an abstraction should be readable in isolation metric, advisory
height proliferation longest chain of hops between abstractions; calls within one module (internal decomposition of a little ball of mud) and within the app layer count as zero altitude, so only real drops between abstractions add depth. Warns past a ceiling (default 5) metric, advisory
passthrough proliferation a public function with 1 caller + 1 callee in another module whose body is a single delegating call — a rename over a different abstraction that hides no decision. Private helpers and same-module calls are internal decomposition and left alone; so are transforms (sub(x) |> Money.new(), %{s | f: Callee.x()}), predicate (name?) forwarders, HEEx components, and macro-generated defs heuristic, advisory
public surface a module exposing more than --max-public-funs (default 12) public functions — a wide surface leaks internals, so the little ball of mud is no longer encapsulated. Aspirational purity: reported by default, scored only under --super-strict metric, advisory / super-strict
layer cohesion advisory metric. Per layer, the share of its functions under one directory (Spray: directories separate layers). 100% = cohesive metric, advisory

Strictness modes

Pair any mode with --min-score N to gate CI at the strictness you want. The required (scored by default) checks are R1, R2, R3, R4, R5, R6, R10, and layer-validity; the advisory ones are R7, module-size, abstraction-height, pass-through, R1-reference (promoted by --strict), plus R11, public-surface, and R10-aggregate (promoted only by --super-strict). R9 is partial (via R1 + R1-reference); R8 is not checked (judgement).

R7, R11, module-size, abstraction height, pass-through, and the reference-level R1 signal are advisory — reported but not folded into the score. Reuse is evidence not a requirement (Spray never demanded a second caller), and a source-encoded app layer legitimately branches, so the tool won't fail a build on these alone. Promote any to a hard failure with --enforce r7 / --enforce r11 / --enforce height / etc. R9 (ports carry paradigm-typed data; no abstraction names its own I/O) is checked only in part, via R1 and the reference-level signal. R8 (reads as the requirements) is not checked — it is judgement. Every run echoes its effective parameters (max_height, max_app_share, max_module_loc, enforce set, layers on/off, weights, config/exclude) so results are reproducible and you know exactly what was and wasn't checked.

R1 altitude, height, and pass-through all run on a function→function call graph (each Module.fun/arity a node, remote calls resolved through the module's alias table). Only R1's no-layer-map fallback (cycle detection) stays at module granularity — a function-level cycle is usually legitimate recursion, whereas a module cycle is the real coupling smell.

R8 (readability) — names, config clarity, "the composition reads as the spec" — is not checked. It is judgement; a high score is necessary but not sufficient. See the ALA Checklist.

Two design choices worth knowing:

The score

Each rule carries a severity weight (coupling core R1/R2/R5 = 3; state R4 = 2; design smells R3/R6/R7 = 1). The headline density is weighted violations per 100 functions — functions, not lines, because the checklist is about how abstractions relate:

W = Σ weight(rule) × count(rule)
density = W / functions × 100 # weighted violations per 100 functions (headline)
per_kloc = W / loc × 1000 # weighted violations per 1000 LOC (secondary)
score = clamp(100 − density, 0, 100) # 0–100, higher is better

Grades: A ≥ 90, B ≥ 75, C ≥ 60, D ≥ 40, else F.

Is a function counted more than once? In the severity score, yes — its numerator counts findings, so a function with several problems weighs several times, and a data-heavy module can exceed 100 findings/function (e.g. a colour-table library scored 3369/100fn). That is why there is a second, bounded metric:

Count of compliant functions (the bounded metric)

offending = distinct functions with ≥1 finding
compliant = functions − offending # functions with ZERO violations (a raw count)
compliant% = clamp(100 − offending/functions×100, 0, 100) # 0–100, higher = cleaner

This counts each function at most once, so it can't be inflated by one function accruing many findings. Findings that belong to no function — R1 cycles, R5 duplicated literals, and magic literals in module attributes — are module-level and reported separately (not folded in).

The two metrics answer different questions and are both reported (both: higher = cleaner):

In a study of popular Hex packages the compliant-function % was far tighter (mean ≈92, σ ≈6) than the degree-of-compliance score (mean ≈64, σ ≈30), precisely because it doesn't multi-count.

Example output

── 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
filesystem cohesion (advisory — Spray: dirs separate layers):
feature 100% under `lib/app/features` (cohesive)
domain 81% under `lib/app/domain` (scattered across 2 dirs)
Degree of function compliance: 92/100 (grade A)
weighted violations: 30 violation load per 100 functions: 7.5
Count of compliant functions: 395 / 399 (99% → grade A)
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 reuse/minimality, abstraction height):
[passthrough] lib/app/x.ex:9 X.forward/1 is a pass-through (1 caller, 1 callee → …)
promote any of these with --enforce <rule>.
Parameters (effective — defaults unless overridden):
max_height: 5 enforce: (none) layers: on …

What else it does

Using it with a coding agent

examples/AGENTS.example.md is a ready-to-use instructions file that teaches a coding agent (Claude Code, Codex, and the like) to write and review code against the ALA Checklist by hand, whether or not this linter is installed. Copy it to your project root as AGENTS.md or CLAUDE.md. It states the down-only rule, the four-rule core, each checklist item as a do/avoid/ask directive, the standard techniques to reach for, and a self-review the agent runs when the linter is absent. When the linter is present, the agent can also run mix ala.lint --strict for the mechanical half.

Status

Reference implementation for the ALA Checklist. R1/R4/R5 are exact; R2/R3/R6/R7 are labelled heuristics tuned to be low-noise (single-clause dead-private detection, whitespace-free contract strings, generated-file skipping). R7, abstraction height, pass-through, the reference-level R1 signal, and filesystem cohesion are advisory (reported, not scored) unless enforced. Contributions and rule-precision improvements welcome.

License

Licensed under the MIT License. Fork and use freely, including commercially; keep the copyright notice.