CLAUDE.md@packages/lint · git:20261002.8d8b5e9 · 2026-10-02 · sha256 f379f69f3a626fc0

CLAUDE.md@packages/lint git:20261002.8d8b5e9A

Immutable. This exact content never changes and is served at /api/v1/blob/f379f69f3a626fc0.

# lint

## Purpose

Loom's own lint: the house rules `gleam check` and `gleam format` do not
know about. Gleam ships no lint command — the compiler checks types and
the formatter arranges bytes, and neither has an opinion about whether an
eagerly-evaluated fallback is expensive, how deep a `case` may nest, or
whether `panic` belongs in `src/`. Those rules were enforced by review
alone until the de-nesting wave violated one of them and shipped a
quadratic JSON parser whose every unit test passed (`08cdbce`).

A pure analysis over `glance`'s AST, plus four `glexer` token scans for
the questions where a parser miss would be a policy hole rather than a
missed suggestion, plus one line scan of a `gleam.toml` and one line
classification of every source. Nine of the nineteen rules gate — R0, R2,
R4, R6, R10, R13, R14, R15 and R16 — and each of their censuses must stay
zero; the other ten report. See **Staging** below before wiring anything else to the exit
code.

Three of the nineteen are not questions about the AST at all. R10 and R11
ask how the source was *laid out* — where the blank lines and comments
are — which `glance` throws away entirely, so `lint/layout` reads the tree
for where each sibling begins and the file's own line table for what was
written between them.

A run is two passes over the sources, not one: R1's structural half needs
every `use`-compatible combinator the run can see before it can judge any
one call site, because a combinator defined in `tools/tool` is called from
sixteen other modules.

`codemode/vet` is the shape this follows — a policy, a set of rules, and
a finding type that carries the reason — for a different purpose. `vet`
decides whether hostile source may run; `lint` advises about source we
wrote ourselves. Nothing here is a security control.

## Key Types

- `lint.check(path, code, policy) -> List(Finding)` — the whole library
  API for a source. Pure, total given `glance` returns, and the only
  entry point anything outside the package should need.
- `lint.check_with(path, code, policy, combinators)` and
  `lint.exported_combinators(path, code) -> List(Eager)` — the same for a
  run that has read more than one file. `exported_combinators` is the
  first pass: the `use`-compatible combinators a source *exports*, keyed
  under its own module path, which is exactly what a qualified call in
  another file resolves to. `check` is `check_with(…, [])` and is what a
  single-file caller — a test, a doctest — should keep using.
- `lint.check_manifest(path, code) -> List(Finding)` — the same for a
  `gleam.toml`. Only R6 has anything to say about one.
- `lint.package_of(path) -> Option(String)` — which package a path
  belongs to, which is the judgement R6 rests on and the census prints
  rows by.
- `lint.is_generated(code) -> Bool` — whether a generator wrote this
  source. The one exemption the rules themselves know nothing about:
  every rule advises an edit, and an edit to `storage/sql`,
  `storage/sql_schema`, `storage/session_schema`, `events/sql` or
  `tools/prelude` is one the next `make gen-sql` discards, so a finding
  there is noise that never clears. The marker is the `DO NOT EDIT.`
  header, read only in the first 400 characters — a module that talks
  *about* the marker further down, as this package's own sources do, is
  still hand-written. `lint/cli` drops these between the reading and the
  two passes.
- `lint/finding.{Rule, Finding, id, name, parse, render, rules,
  error_by_default}` — the vocabulary. `Rule` is `Unparseable |
  EagerFallback | NestingDepth | CatchAll | PanicInSource |
  BoundedLength | PortablePurity | AssertWithoutMessage |
  LoneCallerArity | NakedBool | CommentStanza | DenseStanza |
  BroadClosureCapture | FlowSpine | TransitionTable | StateFirst |
  QualifiedDomainCall | FlowOrder | UnnamedHelper`, printed as
  `R0`..`R18`.
  `error_by_default` is the staging decision as data — `[Unparseable,
  NestingDepth, PanicInSource, PortablePurity, CommentStanza, FlowSpine,
  TransitionTable, StateFirst, QualifiedDomainCall]` — and its doc comment
  carries one census and one argument per rule, which is what a reader
  who has just been failed by one of them needs. `gate(findings, errors)`
  is the `#(errors, warnings)` split `lint/cli` prints as its last line
  and `scripts/lint.sh` turns into an exit code; it is public because a
  promotion is only real if it moves that number, and a test that asserts
  a rule *fires* has not tested the gate.
  `Finding` carries the rule, the path, the **line**, the enclosing
  function and a one-line detail phrased so a reader can act on it.
- `lint/portable.{externals, imports, manifest}` — R6, and the whole
  argument for it: what the portable subset protects, why the rule names
  no target, and why "portable" does not mean the harness runs in a
  browser. Read its module doc before touching the rule; the three
  findings it emits carry that argument into the report.
- `lint/policy.{Policy, default, for_tests, for_tests_like, for_package,
  harness_packages, Eager, eager_combinators, Counted, counted_calls,
  portable_packages, BeamOnly, beam_only_dependencies}` —
  what the rules are tuned with: the R2 nesting threshold (3), whether
  `panic` is allowed (it is, in `test/` — and in the `src/` of a package
  `harness_packages` names, which `for_package` keys off and `lint.check`
  applies from the path), and whether R3 looks at multi-subject `case`s
  (it does not). `assert_message` (R7, off for tests
  alongside `allow_panic` — `for_tests_like` is the one place that decides
  what a test source is exempt from), `lone_caller_arity` (R8's
  threshold, 7) and `dense_stanza_run` (R11's, 8 statements).
  `counted_calls` is R5's table: the two counting calls it
  watches and, per call, the bounded question to suggest instead, because
  the advice for a string is not `list.drop`. `eager_combinators` is R1's
  hand-curated table — module, function, the *label* of the eagerly
  evaluated argument, its positional index, and the lazy counterpart to
  suggest — for the six stdlib combinators plus the rare locally-defined
  one the structural check below cannot reach on its own (`tools/fs`'s
  `require`, which takes no continuation at all).
- `lint/scan.{Raw, module, cheap, exported_eager_rows}` — the AST walk. `Raw` is a finding
  before it has a line: a rule, a **byte offset**, a function name and a
  detail. `cheap` is R1's trivially-cheap predicate, public because it is
  the single judgement the rule rests on and it must be testable alone.
  `module` also takes the file's own module path now (`lint.module_path`
  derives it from the source path), which is what lets a call to a
  locally-defined combinator resolve at all and what R1's structural
  half keys a file's synthesized `Eager` rows under. `module` also takes
  the rows the run collected from every *other* file
  (`exported_eager_rows`, public functions only), and drops any keyed
  under its own path so a combinator called where it is defined is one
  finding rather than two.
- `lint/layout.{Block, Step, Weight, blocks, offsets, findings}` — R10 and
  R11, which are the only rules about how a file was *written* rather than
  what it parses to. `blocks` walks the tree for every list of
  siblings — `Statements` of a body, `Branches` of a `case`, `Variants` of
  a custom type — and reports
  where each one begins, in offsets; `offsets` is what
  `source.line_map` resolves in one merged pass; `findings` then judges in
  line numbers against `source.classify`'s table. `Weight` is the R11
  narrowing as a type: a `use` binding carries a run across without
  lengthening it, because a `use`-chained decoder is a table of fields and
  not a paragraph of steps.
- `lint/source.{line_starts, lines_of, line_of, keyword_offsets,
  Keywords, external_offsets, variant_offsets, LineKind, Lines, classify,
  kind_of, offset_of, line_map}` — byte offsets to lines, the four token
  scans (R4's backstop, R6's `@external` half, the comment scan `classify`
  rests on, and `variant_offsets` for the variant heads `glance` gives no
  span for), and the line classification the layout rules index.
  `classify` reads comments from `glexer`'s tokens rather than from the
  text, so a line of a multi-line string beginning `//` is code.
- `lint/module_doc.{DocLine, Section, lines, section, code_spans}` — the
  module doc as R13, R14 and R18 read it: `////` lines from `glexer`'s
  comment tokens with the byte offset of each, so a `////` inside a
  multi-line string is not doc. One reader for three rules, so they cannot
  disagree about what the module doc says.
- `lint/calls.{callers_of, private_helpers, mentions}` — the in-module call
  graph R8, R17 and R18 share: which functions of a module mention a name,
  sorted by position. Recursion is not a caller, and a reference from a
  constant is invisible.
- `lint/spine`, `lint/transitions`, `lint/state_first`, `lint/qualified`,
  `lint/flow_order`, `lint/unnamed_helper` — R13 to R18, one module per rule
  with the same `findings(module, code, lines, policy, own_path)` shape,
  wired side by side in `lint.check_with`. `state_first.step_names`,
  `qualified.loom_roots` and `qualified.allowed` are their tables as data.
- `lint/cli.main` — argument parsing, file discovery, the generated-source
  skip, the report and the census. The only module here that does I/O.

## Relationships

- **Depends on**: `glance` (>= 7, for an AST that carries a `Span` on
  every expression and pattern), `glexer` (the R4 backstop and R6's
  `@external` scan), `simplifile` (file and manifest discovery), `argv`. **No Loom package**, in either direction —
  `lint` is a developer tool, not part of any plane, and nothing in the
  harness may call it.
- **Depended on by**: nothing. `scripts/lint.sh` runs it, `make lint`
  and `scripts/check.sh` call that.
- **FFI**: none.
- **Note on `glance` versions**: `codemode` pins `glance` 1.x because
  `vet`'s rules and its `Vetted` token are written against that AST.
  `lint` needs 7.x: 1.x cannot parse label shorthand in patterns
  (`Int(value:)`), which appears throughout this tree, and 7.x is what
  puts a `Span` on every node. The two packages build independently, so
  the pins do not interact — but do not "unify" them without reading
  `codemode/vet` first.

## Traffic

No actors, no registers, no wire messages. `lint/cli` reads files with
`simplifile` and writes to stdout; everything else is a pure function.
Each source is read once and parsed twice: once by
`lint.exported_combinators` to collect R1's cross-module table, once by
`lint.check_with` to lint it against the whole table. Over sixteen
packages the second parse costs about a second. Each source is also
*classified* once — `source.classify` lexes it for comment tokens and
indexes its lines — which is what R10 and R11 read; a whole run is under
two seconds.
It reads `gleam.toml` as well as `.gleam`: the manifests are *derived*
from the sources a run touched (the package root above each `/src/` or
`/test/`) rather than named, because `make lint` points at
`packages/*/src` and R6's other half would otherwise never be reached.
The last line of a run is `# <errors> <warnings>`, which is the contract
`scripts/lint.sh` reads to decide its exit code — the same shape
`scripts/doc_check.sh` uses.

## The rules

- **R1 `eager-fallback`** — an eager combinator whose eagerly evaluated
  argument is not *trivially cheap*. Gleam evaluates call arguments
  unconditionally, so an eager guard's `return:` is constructed on every
  call, taken or not. This is a correctness hazard when the argument
  recurses and a performance hazard when it is merely expensive;
  `core/json` was the second kind, and the cost was paid entirely on the
  happy path, which is why every test stayed green. Trivially cheap means
  a literal, a bare variable (also how a nullary constructor is spelled),
  a record-field read, a tuple index, a closure, or a constructor applied
  only to those. A call, a `<>`, a pipeline, a block, a `case` — all flag.
  Arithmetic and comparison over cheap operands stay cheap; `<>` does
  not, because it allocates in proportion to its operands.

  A call qualifies two ways. The **hand-curated** half is
  `bool.guard`, `result.replace_error`, `result.unwrap`, `option.unwrap`,
  `result.or`, `option.or` plus the odd locally-defined row
  (`policy.eager_combinators`) — known by name. The **structural** half
  finds this tree's own `or_fault` lineage —
  `or_fault`, `or_fault_unless`, `or_fail`, `or_outcome`, `or_reply`,
  `or_continue`, `or_halt`, `or_key_halt`, and whatever the next file
  adds — by *signature* rather than by name: a locally-defined function
  whose last parameter is `fn(…)` and whose other parameters are not is
  `use`-compatible the same way `bool.guard` is, per the style guide's
  own house pattern ("the fallible subject first, the continuation
  last"). The subject at position 0 is exempt — it is meant to run
  unconditionally, not a discarded fallback — so only a parameter
  *between* the subject and the continuation is checked, which is why a
  two-parameter combinator (`or_fault`, `or_halt`, `or_key_halt`) never
  flags and the report does not flood across the whole lineage.
  A last parameter that is `fn(…)` is not enough on its own: it must be a
  **continuation**, meaning it produces the function's own return type,
  the way `or_fault(result, then: fn(a) -> Action) -> Action` does. A
  function that merely takes a closure last is doing something else with
  it — `call.try_call(subject, waiting:, sending: fn(reply) -> message)`
  builds a request with `sending:` and uses `waiting:` unconditionally —
  and while the table was built one file at a time this stayed implicit,
  because such a function had to sit beside its own call sites to be seen
  at all. Reading the whole tree's exports made it load-bearing: the first
  row the cross-module pass added was `try_call`'s, and it was wrong.
  The **body** is read as well as the signature (issue #73, C): the
  combinator must branch on its own subject — the leading `case`'s
  subject, or the leading `use <- guard(…)`'s first argument — and a
  parameter named in that decision is not reported, because the callee has
  already used it by the time it chooses. A signature alone got that wrong
  nine times in thirty-three: `session.read_cell`'s `key:` goes straight
  into the register read, and `claimed_effect`'s `key:` drives the claim
  check itself. Both halves are decidable without types, and both fail
  conservatively — a body shape the walk does not recognize drops its rows
  rather than inventing them. Neither *proves* the argument is wasted, so
  the rule still reports rather than gates.
  The rows are collected across the whole run (issue #73, D). A row is
  keyed under the module that defines the combinator, which is exactly
  what a qualified call resolves to, so nothing in the lookup changed —
  only where the rows come from: `lint/cli` reads every source once,
  collects `lint.exported_combinators` over all of them, and hands that
  table to each file. Before it, `tool.or_outcome`'s nineteen call sites
  were visible only in the three that share its file. The pass added
  exactly one finding across the tree, the `try_call` false positive
  above; with the continuation narrowing in place R1's census is
  unchanged — which is the result, since those sixteen were clean by luck
  and are now clean by coverage.
- **R2 `nesting-depth`** — a function whose `case` expressions nest
  deeper than the threshold. Measured on the AST, never on indentation:
  `client/protocol.gleam` and `machine/codec.gleam` look deep to a column
  counter and are wide literals the formatter broke one argument per
  line. That distinction is the whole reason this tool parses.
- **R3 `catch-all`** — a final arm in a single-subject `case` that matches
  regardless of the subject, where the other arms are flat constructor
  patterns. **Both spellings**: `_ ->` and a bare variable, `other ->`,
  which is a catch-all whatever it is called (gleam-style Part III) and
  which the rule was blind to until issue #73 — seventy-three arms, which
  is R3 from 134 to 206 on its own, including every one of the serious
  ones the baseline review found and all nine of `core`'s. The finding says which spelling it found and, for
  a named one, whether the arm reads the binding: a `_ ->` can often be
  deleted and the variants written in its place, while an `other ->` whose
  arm reads `other` means every enumerated arm has to name the value.
  (In a tree that compiles warning-free the second is the only kind there
  is — an unread binding is a compiler warning, and would be spelled
  `_other` — so the census is 73 named and 0 unread. The rule still asks,
  because a linter reads sources the compiler has not accepted yet.)
  Three narrowings keep it from drowning the report, all decidable without
  types: a `case` where any pattern anywhere matches a literal is skipped
  (no enumeration of `Int` exists, so `_ ->` is mandatory); a `case` whose
  arms match *combinations* — `Ok(Some(Cell(..)))` — is skipped, because
  `_ ->` there stands for the remaining combinations rather than for a
  sibling variant; and a `case` **any** of whose arms carries a guard is
  skipped, because a guarded arm cannot be exhaustive on its own, so the
  final arm is mandatory and a finding about it is always wrong (twelve of
  them in the first census). **R3 still over-reports and cannot stop**:
  see below.
- **R4 `panic-in-src`** — `panic` or `let assert` outside `test/`, and
  outside the `src/` of a package `policy.harness_packages` names.
  Loom policy forbids both; nothing else enforced it. The exemption is
  `conformance`, a test harness that compiles as a library, and it is
  about *presence* only: Part IV rule 3 also demands an `as "message"` on
  every admitted `let assert`, none of those ninety carries one, and no
  rule checks it yet (issue #73, item F).
- **R5 `bounded-length`** — a count compared against anything that is not
  another count. Two counts, from `policy.counted_calls`: `list.length`
  and `string.length`. The bound need not be a literal:
  `list.length(xs) > max_results` walks the whole list to answer a
  question settled at `max_results`, and in this tree that is the
  commoner spelling. This was the other half of the `core/json` bug —
  `excerpt` ended with `list.length(rest) > 24`, which is what made a
  hot-path error construction quadratic rather than merely wasteful. The
  string spelling was invisible until issue #73 and hid ten sites, two of
  them hot: `core/corruption.bound`, on every corruption report the tree
  builds, and `telemetry/field.secret_shaped`, once per token of every
  scrubbed log line including OTP report lines. The finding names the
  bounded question for the thing that was counted — `list.drop(xs, k)`
  against `[]`, `string.drop_start(text, k)` against `""` — because the
  advice for a string is not the advice for a list.
- **R6 `portable-purity`** — an `@external`, a BEAM-only import, or a
  BEAM-only dependency in `core`, `machine` or `prompt`. Those three hold
  none of the three today, by rule rather than by coincidence, and two
  properties rest on that: the operation state space stays
  property-testable without spawning processes (the reason `machine`'s
  doc always gave), and the three stay compilable to the JavaScript
  target (the reason nothing gave until issue #92). The rule names **no
  target**: an Erlang external ends the portability, a JavaScript one
  breaks the BEAM build Loom ships on, and a matched pair still puts
  trusted-unchecked foreign code where the purity claim is. The
  `@external` half reads tokens rather than the AST so that an external
  in a file `glance` cannot parse is still reported rather than reduced
  to an R0 warning; the import half reads the AST, where a module path is
  one string; the `gleam.toml` half is a line scan, because the question
  is only whether a name appears as a key and a TOML parser would be a
  dependency bought for three lines. `lint/portable`'s module doc carries
  the argument, and every finding carries enough of it that a reader can
  tell whether their case is the exception worth arguing.
- **R7 `assert-without-message`** — a `let assert` in `src/` with no
  `as "…"`. R4's other half rather than a second opinion about R4's
  question: R4 asks whether the construct belongs in this file at all and
  `harness_packages` answers "here, yes", after which Part IV rule 3 asks
  what the crash report will say. A bare `let assert Ok(x) = …` answers
  with a pattern and a line number, which is the whole of what an operator
  gets. So the rule reaches the harness R4 exempts — where every one of
  its findings is — and goes off for `test/`, where the test's own name is
  the message. Its census is 84 and it warns; see **Staging**.
- **R8 `lone-caller-arity`** — a private function with more than
  `lone_caller_arity` parameters (7) and exactly one caller, where a
  caller is another function in the same module whose body mentions the
  name. Not a hazard: a census, like R3. What it measures is the thing a
  nesting metric rewards and never charges for — a block lifted out of its
  caller with the caller's locals re-declared as a signature, which reads
  shallower, threads the same state by hand, and is checked by nothing but
  the argument order. It over-reports by construction (some of these grow
  a second caller next week), under-reports where a name is shadowed, and
  does not see a reference from a constant. It would have named
  `reconcile_orphaned_poll` — thirteen parameters, one caller — on the
  commit that introduced it.
- **R9 `naked-bool`** — a `Bool` in a function parameter or a record
  field. `Bool` is the one type in the language carrying no domain
  meaning: `render(document, True)` names nothing at the call site, and a
  field declared `retry: Bool` makes every reader carry the polarity of
  the name and makes `Retry | GiveUp` a change to every construction site
  rather than to one declaration. gleam-style has said "replace booleans
  with two-variant custom types" since it was written; nothing enforced
  it, and the tree holds 223. **Return position is deliberately outside
  the rule**: `is_empty(xs) -> Bool` is the predicate `case`, `&&` and
  `bool.guard` are built to consume, and flagging those would flag the
  language. The search descends through type parameters and tuples —
  `Option(Bool)` in a field is the same hazard with a third state on it —
  and stops at a `fn(…)`, because a predicate *passed* to a function is
  that same legitimate `is_*` arriving as an argument. That boundary is
  the rule's one narrowing and it is decidable from the annotation alone.
  Type aliases and constants are not searched.
- **R10 `comment-stanza`** — a `//` comment between two siblings, with
  code on the line directly above it. A comment welded to the line above
  reads as that line's footnote; the blank line is what makes it the
  heading of the stanza below, and that is the difference between prose a
  reader can find and prose they cannot (`CLAUDE.md`, "Literate code").
  Siblings are of three kinds: the statements of a body, the **arms of a
  `case`** — the reasoning for an arm is the commonest place this repo
  writes prose inside a function — and the **variants of a custom type**,
  which is where most of the tree's `///` prose lives and which was 720 of
  the original 1137. A variant head is found by token scan rather than by
  the AST, because `glance.Variant` carries no `Span` at all
  (`source.variant_offsets`); the enclosing `CustomType` does, which is
  what keeps that scan from having to recognize a type on its own.

  **Three exemptions, and every one of them is the formatter's decision
  rather than the rule's.** A comment that is the **first line of a
  block** is exempt because `gleam format` deletes a blank line at the top
  of a function body or a `case`. A comment between two **fields of a
  constructor** is exempt because the formatter deletes that blank line
  too — and this is the sharp one, since the formatter *preserves* it
  between two variants of the same type, so the rule reaches variants and
  not fields. A comment **inside a wrapped literal** is invisible, because
  it annotates an element of a data structure rather than opening a
  stanza; that one is R2's "a wrapped literal is not depth" in the layout
  register, and without it the rule would flood every encoder in the tree.
  The first two are checked facts, not guesses: `gleam format --stdin`
  over each shape says so, and the whole-tree sweep passing
  `gleam format --check` is the standing proof.
- **R11 `dense-stanza`** — a function whose longest run of statements with
  nothing between any two of them exceeds `dense_stanza_run` (8). A body
  with no paragraphs has nowhere to put the prose R10 is about, so density
  and silence arrive together; `runtime/supervisor.start` was ten `let`s
  in a wall and reads as three stanzas once broken. Counted in
  **statements, never lines**, for R2's reason: a thirty-line
  `json.Object([…])` the formatter wrapped is one step. And a `use`
  binding is weightless — it carries a run across without lengthening it —
  because a `use`-chained decoder is a table of fields written down the
  page and not a paragraph of steps. That narrowing is what took the
  census from 57 to 17; `core/codec.decode_assistant_message` is nineteen
  `use field <- result.try(…)` lines and is exactly right. One finding per
  function, at the function, the way R2's is: what a reader does about it
  is re-read the body and decide where its paragraphs are.
- **R12 `broad-closure-capture`** — a closure which is returned, assigned,
  or stored in a constructor, and which uses an outer binding only as the
  direct container of field
  access. A bare use, record update or shorthand argument drops the finding,
  and lexical bindings are tracked so a shadowed name is not charged to the
  outer value. Nested closures own their findings. This is the syntax behind
  PR #411's imported-hook, compaction-projection and tool-output-observer
  retention bugs: project the fields before constructing the closure and its
  environment no longer retains the complete record. The September 14 census
  is 85. It stays a warning forever because `glance` has no inferred record
  widths or closure lifetimes. Ordinary callback arguments are excluded too,
  since deciding whether an arbitrary callee retains one would require
  interprocedural analysis.
- **R13 `flow-spine`** — a module of `policy.spine_lines` (1000) or more
  lines whose module doc has no `//// ## Flow` section, or any module whose
  Flow section is stale. The spine is the map a reader dropped in by "go to
  definition" lacks: the functions on the main path, in order, as a short
  numbered list. The section runs to the next level-1 or level-2 heading.
  Every backtick span in it that is a bare snake_case name (optionally
  `(...)` or `/N`) must be a function the module defines, public or private;
  `alias.name` must have an `alias` the module imports (the function behind
  it is the other file's concern); an UpperCamel type or anything with a
  space is prose and unchecked. The section must name three distinct local
  functions and holds no fenced block, since a fence is where a stale name
  would hide. The doc is read from `glexer`'s comment tokens by
  `lint/module_doc`, so a `////` line inside a multi-line string is not
  doc. A missing spine is one finding at the top of the file; the rest are
  at the offending line or the heading.
- **R14 `transition-table`** — a `<!-- transitions: module.Type -->` marker
  line in the module doc, followed by a markdown table, checked against the
  custom type it names. `module` is this module's last path segment or its
  full path and `Type` must be a custom type defined here. The first cell of
  each body row, backticks and space stripped, is a constructor: a missing
  constructor (one finding, at the marker), a row naming no constructor and
  a repeated row (at the row) are findings, as is a row whose cell count
  differs from the header's or that has an empty cell. A marker for another
  module, for a non-type, or with no table after it reports that one
  finding and stops. The cells themselves stay prose; only the row set is
  checked, so adding a state without a row fails the gate.
- **R15 `state-first`** (`lint/state_first`, issue #593) — a state-space
  type defined after the module's first function. A module is a state
  machine when it defines a step function named in `step_names`
  (`update`, `step`, `transition`, `handle_message`, `handle`); the
  state-space types are the module's own custom types and aliases that
  its signature names, searched through type arguments, tuples and
  function types, and each must begin before the first function. A late
  one is a finding at the type, naming the step function and the function
  it must precede. Decidable on the AST alone: no types, only offsets.
  The name table was fixed by census: `handle` is the actor spelling and
  every one of its twenty-two findings was a real state space, while
  `apply` and `next` added only a fold and an iterator cursor, and `loop`
  and `reduce` added nothing. `findings_named` takes the table so a census
  can try a candidate before it is admitted.
- **R16 `qualified-domain-call`** (`lint/qualified`, issue #593) — an
  unqualified *value* import (`import a/b.{name}`, `name` lowercase) from
  one of Loom's own modules, where the qualifier is the closest thing
  Gleam has to a method receiver. Types and constructors never flag.
  "Loom's own" is `loom_roots`, the first path segment of every module in
  `packages/*/src`; a test reads the tree and fails if the list drifts.
  The MCP SDK does not collide with the `mcp` root, because its modules
  live under `gleam_mcp/`. `allowed` is the `#(module, name)` escape for a
  name that reads better bare, such as a `use` continuation combinator; it
  is empty because the census (two constants) found none worth keeping.
- **R17 `flow-order`** — a module where strictly more than
  `flow_order_percent` (50) per cent of its private helpers are defined
  above their first caller, once it has `flow_order_min_helpers` (10) of
  them. A helper is a private function with at least one in-module caller;
  public functions are entry points and never counted; "above" means it
  starts before the earliest-defined of its callers. One finding per module,
  at offset 0, naming up to three examples. A census, never a gate: a leaf
  grouped under its siblings is a fair reason to disagree. The call graph is
  `lint/calls` (shared with R8 and R18): recursion is not a caller and a
  reference from a constant is invisible. Whole-tree census is zero at the
  defaults; the worst module is `cap/actor` at 27 per cent, so the tree
  already reads entry-point first and the rule is a regression guard on
  that, not a backlog.
- **R18 `unnamed-helper`** — a private function with exactly one in-module
  caller, spanning at most `unnamed_helper_lines` (6) lines from `fn` to the
  closing brace (doc comments excluded), whose name is not a whole word of
  the module doc. Suggestion 4 of issue #593 as a census: an extraction buys
  a name, and a helper that names no domain operation buys only a jump. The
  module doc is read from `glexer` tokens, so a `////` inside a string does
  not count. It over-reports by construction (a short helper is often
  right), so it warns forever; the census is 884, more than half of it at
  exactly six lines, which is where the formatter lands a short `case`.
- **R0 `unparseable`** — not a house rule. A file `glance` could not
  parse is reported, so a parse failure is never silence.

## Staging

**R0, R2, R4, R6, R10, R13, R14, R15 and R16 gate; R1, R3, R5, R7, R8, R9,
R11, R12, R17 and R18 warn.** `make lint` and `make check` fail on any of the
nine. The warning default is deliberate
and it is the `scripts/doc_check.sh` precedent (D2,
`docs/design-notes/four-decisions.md`): a check earns the error tier by
producing a census that is zero, decidable, and argued — not by being
written. A lint that fails correct code gets disabled.

R6 met that bar on the day it was written rather than later, which was
the whole of its case. Its census is **zero**, it is decidable without
types (an attribute is present or it is not; a module path is under a
prefix or it is not; a key is in a manifest or it is not), and keeping
that zero is the entire point. A rule whose job is to hold a door open
cannot do it from inside a report of two hundred and sixty warnings;
shipping it as a warning would be the failure it exists to prevent, in a
milder costume.

The other three arrived at that same condition by measurement, once the
baseline was triaged (issue #73). Each has its own census and its own
reason to stay at zero, and `finding.error_by_default`'s doc comment
carries both — that is what a reader who has just been failed by one of
them needs in order to tell whether their case is the exception worth
arguing.

- **R0** is zero because `glance` 7 parses every file in the tree, and it
  is decidable in the strictest sense available here: the parser returned
  a module or it did not. What promotion protects is *the rest of this
  list*. Every rule but R6's token half is silent about a file that will
  not parse, so an unparseable file is the linter switched off for that
  file — and at warning level nobody decided to switch it off, which is
  the difference between an exception and an accident.
- **R2** is zero at threshold 3 across all sixteen packages: no function
  nests `case` more than three deep. That is the de-nesting sweep's one
  verifiable result rather than a rule nothing has tested — thirty-seven
  functions sit at exactly 3, so the threshold is a boundary the tree
  leans on and not a ceiling far overhead. Decidable on the AST, so a
  wide literal the formatter wrapped is not depth. Promotion protects a
  property that is only ever lost one `case` at a time, each of which
  reads as reasonable on the day it lands.
- **R4** is zero once `policy.harness_packages` exempts `conformance`,
  whose `src/` is a test harness that has to compile as a library — the
  ninety findings it held were a third of the census and none was signal.
  `panic` and `let assert` are syntax, so the rule is decidable, and the
  token backstop means a construct the parser dropped is reported rather
  than assumed inert. This is the one rule `CLAUDE.md` and gleam-style
  Part IV state in as many words, and until the promotion the distance
  between a stated rule and an enforced one was exactly this flag. The
  missing `as "message"` is R7's, not R4's.

Promotion of the remaining six is per rule and costs one flag,
`scripts/lint.sh --error=R5` — except R3 and R8, which can never be
promoted at all. The census over `packages/*/src` reads (after the
`conformance` exemption, R1's body check and its cross-module pass, R3's
two spellings and its guard narrowing, R5's string spelling and the four
fixes in `core`, `telemetry` and `lint` that followed it; see `git log`
for the censuses this superseded):

| rule | findings | disposition |
| --- | --- | --- |
| R0 unparseable | 0 | **error level**; `glance` 7 parses the whole tree |
| R1 eager-fallback | 20 | real, and all performance-only on cold paths; promotable once they are fixed |
| R2 nesting-depth | 0 at threshold 3 | **error level**; a regression guard, promotable precisely because it finds nothing |
| R3 catch-all | 195 | **stays a warning**; undecidable without types |
| R4 panic-in-src | 0 | **error level**; `conformance/src` is exempt by package and was the whole census |
| R5 bounded-length | 6, in `cap`, `client`, `prompt`, `provider` and `tools` | precise; promotable once those five are fixed |
| R6 portable-purity | 0 | **error level**; zero is the invariant, not the starting point |
| R7 assert-without-message | 84, all in `conformance/src` | Part IV rule 3 at nothing per cent; a rule cannot gate on a census it has never been at zero for |
| R8 lone-caller-arity | 3, and moving | **stays a warning**; a shape, never a verdict |
| R9 naked-bool | 223 | decidable; promotable once the sweep lands and the four irreducible sites are the census rather than 2% of it |
| R10 comment-stanza | 0, swept from 1137 | **error level**; zero in `src/` and `test/` alike, and the sweep is formatter-verified |
| R11 dense-stanza | 17 at threshold 8 | precise, but the threshold is a judgement; treat the number as a reading |
| R12 broad-closure-capture | 82 | **stays a warning**; record width and closure lifetime are absent from the AST |
| R13 flow-spine | 0, swept from 96 | **error level**; every module of 1000+ lines has a checked spine |
| R14 transition-table | 0, nine tables | **error level**; zero by construction, and the gate is the rule's whole purpose |
| R15 state-first | 0, swept from 47 | **error level**; every fix a pure move the compiler verified |
| R16 qualified-domain-call | 0, swept from 2 | **error level**; decidable from the import alone |
| R17 flow-order | 0 at 50% | **stays a warning**; a shared helper has no single right place |
| R18 unnamed-helper | 859 | **stays a warning**; whether a name is a domain operation is judgement |

R13 to R18 arrived together for issue #593, and the argument for each tier
is in `finding.error_by_default`. The four that gate were driven to zero in
the change that wrote them, the way R10 was. What makes R13 worth a gate
rather than a report is its second half: a spine is prose that names code,
and the check that every backticked name is a function the module defines
is what turns a rename into a build failure instead of a spine that lies.
R14 was zero before any table existed and exists for the gate alone. R17
and R18 are R8's kind of measurement and never gate; R18 in particular is a
reading list for a reviewer, and padding a module doc with helper names to
silence it would destroy what it measures.

R1's twenty are what the triage left after the body check removed nine
false positives: every one is a real eager argument, none of them
recurses (both reviewers checked the whole `or_fault` lineage), and the
cost is a wasted allocation on a cold path. Promoting a rule the same
season its predicate changed is how a linter starts failing correct code,
so R1 warns until its census is zero and stays there.

R5's six are what is left of ten after the four in packages this change
owned — `core/corruption.bound`, `telemetry/field`'s two, and this tool's
own census padding — were fixed. The remaining files (`cap/git`,
`client/agency`, `provider`'s two adapters, `tools/agent`; `prompt/summary`
was one until the summarizer it served was removed) are the same one-line
shape and the same promotion is waiting on them.

R7 is not R4 with a longer message, and inheriting R4's exemption would
make it report nothing at all: every one of its eighty-four is in the
harness `harness_packages` admits the construct in. Ninety `let assert`s
that name no invariant is a house rule at nothing per cent a full sweep
after it was written, which is worth a number even though — especially
though — fixing it is a separate change from counting it.

R8 is R3's kind of rule and reaches the error tier never. "More than
seven parameters and one caller" is a shape worth looking at, not a
defect: a helper grows a second caller, a wide signature is sometimes
the honest one, and the caller count is over-approximated by a name
match. Its number moves under the reader: it was 49 when issue #73
measured it, 40 of them in `machine/planner.gleam`, and 10 when this
change landed the rule — the planner rewrite is removing the population
it was written to measure, which is the rule working rather than the rule
failing. Do not tune the threshold to make the number look better; 7 is
where the tree's own signatures stop being readable at a glance.

R10 is the newest gating rule, and it met the bar the way R6 did rather
than the way R2 did: by being driven to zero in the change that wrote it.
The census was **1137** across the eighteen packages and it is **zero**
now, in `src/` and in `test/` alike. It is decidable without types — a
line is blank or it is not — and the fix is one blank line, never a change
to what the code does.

What makes that promotion safe rather than brave is that the sweep was
verified against the only authority that could contradict the rule.
`gleam format --check` passes on all eighteen packages after 1137
insertions, so no finding ever asked for a blank line the formatter would
take away — the failure mode that would have made the rule unsatisfiable.
The two exemptions exist for precisely that reason: a comment opening a
block and a comment between two fields of a constructor are both places
`gleam format` deletes a blank line. **Never add a third sibling kind to
`lint/layout` without checking the formatter first**; the check is one
`gleam format --stdin` and it is the difference between a rule and a trap.

The property is also worth a gate. It is lost the way R2's is — one
comment at a time, each reasonable on the day it lands — and it is
invisible in review, because a welded comment reads fine inside a diff
hunk that begins above it.

R9 and R11 warn, which is the ordinary staging, and R9 is decidable enough
that the question of promotion is worth answering rather than deferring.

R9's 223 are every one decidable — an annotation says `Bool` or it does
not — and what stops promotion is that they are not *fixable* in one
change. Two hundred declarations is a sweep in its own right, and some are
not this repository's to make: `terminate: Bool` and `from_hook: Bool` are
fields of frozen Part-1 contracts, so replacing them costs a
`protocol-change/NNN.md` rather than an edit. Four more are irreducible
and always will be — `core/json`'s `Bool(value: Bool)`, `core/msgpack`'s
`BoolValue`, `cap/wire`'s `bool` encoder and `core/codec`'s
`encode_default_false` are code *about* booleans. A rule with four
permanent exceptions can still gate, the way R4 gates with a whole package
exempted, but only once the exceptions are the census rather than 2% of
it.

R11's 17 are precise, but its *threshold* is a judgement rather than a
fact. Eight statements is where this tree's own bodies stop having
paragraphs, measured — but "eight" is not decidable the way "blank or not"
is, and a rule whose census moves when somebody argues about a number
should not be able to fail a build. Do not tune it to make the number look
better, and read it as R8's kind of measurement.

R3 is the doc-check `symbol absent from file` case: deciding whether an
arm *could* have been exhaustive needs the subject's type, and `glance`
resolves no types. The three narrowings above remove the classes that are
decidably not exhaustive-able; what is left mixes genuine variant
dispatch with idiomatic two-arm predicates, and the finding text says
which shape it is rather than pretending the distinction is not there.
An undecidable finding stays a warning forever, and says why.

The staging lives in `finding.error_by_default`, not in
`scripts/lint.sh`, so a promotion needs no flag, no `Makefile` change and
no wrapper edit — and `finding.gate` is the `#(errors, warnings)` split
that decision produces, which is what the tests assert against. A test
that a rule *fires* is not a test that it gates.

## Invariants

- **Total, given `glance` returns.** A file that will not parse is one
  `R0` finding, never a crash; an expression the walker does not model
  contributes nothing rather than an error. The one residual is inside
  the parser: `glance` carries hard `panic`s ("parser bug, expression not
  full reduced") on paths no fuzzing has reached, and one would propagate
  out of `glance.module`. This is exactly the claim `codemode/vet` makes
  about the same parser and it is **no stronger** — do not upgrade the
  wording without a `rescue` at the boundary to back it.
- **No `panic`, no `let assert` in `src/`.** The tool passes its own R4;
  `make lint` says so.
- **R10's census is zero across the whole tree and must stay zero.** It
  gates, so a welded comment fails `make check`. The fix is always one
  blank line above the comment, never a change to the code, and the two
  places the rule does not reach — the top of a block, and between a
  constructor's fields — are the two places `gleam format` deletes a blank
  line. If a finding ever appears that cannot be fixed that way, the
  formatter has changed and the exemptions are what to re-derive; do not
  demote the rule.
- **The tool passes its own R10 and R11.** `make lint-lint` reports zero
  of each. Its R9 census is 13 and is part of the tree-wide sweep the rule
  schedules, which is a different change from the one that counts them.
- **Every gating rule's census is zero and stays zero.** R0, R2, R4 and
  R6 are wired to the exit code by default. If a finding appears the
  answer is to fix the source, not to demote the rule: an exception needs
  the argument in `finding.error_by_default` — and, for R6, in
  `lint/portable` — answered rather than sidestepped. The one legitimate
  way to widen R4 is to add a package to `policy.harness_packages`, which
  is a decision that has to be written down in that package's own
  `CLAUDE.md` as well: a tree exempted silently is a tree nobody knows is
  exempted.
- **Every `case` over a `glance` type is exhaustive.** No `_ ->` over an
  AST node: when `glance` adds a syntax node, this package must fail to
  compile rather than silently stop seeing it. (The catch-alls this
  package's own R3 reports are over *its own* small types, not over
  `glance`'s.)
- **Every rule has a positive and a negative test, and every gating rule
  has a test that it gates.** A rule that cannot fail is worse than no
  rule — it reads as coverage and provides none; and a promoted rule
  tested only by "does it fire" is tested at the tier it was already at,
  since it fired before the promotion too. The gating tests go through
  `finding.gate`, the same `#(errors, warnings)` split `scripts/lint.sh`
  turns into an exit code, and R4's pair runs the same source under
  `core` (gates) and under `conformance` (silent), which is the exemption
  itself under test.
  R3's guard narrowing is tested from both sides — the same `case` with
  and without a guarded sibling — so removing the check fails a test
  rather than silently restoring twelve false positives, and R8's four
  conditions (private, over the threshold, exactly one caller, recursion
  not counted) each have a negative case.
  `test/lint_test.gleam` also carries the `core/json` regression shape
  verbatim, so R1 is pinned to the bug that motivated it — and, for R1's
  structural half, the `or_fault_unless`/`require` shapes issue #56 found
  invisible, one pinned by signature and one by the hand-curated table
  row, each with a negative case proving the row is lazy and path-scoped
  rather than merely absent. R6's negative cases are the ones that pin
  the *shape* of the check: a `@external` inside a string and inside a
  comment (proving the scan is over tokens, not text), a
  `gleam/erlangish/thing` import (proving a prefix is a path segment),
  and a commented-out dependency line (proving the manifest scan keys on
  the key rather than on the substring).
  The layout rules' negative cases are the narrowings: R10's are the
  formatter-forced exemptions (a comment opening a body, a comment above
  the first `case` arm) and the wrapped-literal case, and R11's are the
  `use` chain, the wrapped literal, and the same body broken by a blank
  line and by a comment. Each of those is a class the rule would flood on
  if the narrowing were removed, so removing one fails a test rather than
  quietly costing the census its meaning.
- **A finding is reported once.** R1's table now reaches a file from two
  directions — its own rows and the run's collected ones — and a
  combinator called in the file that defines it is in both. `scan.module`
  drops the imported rows keyed under its own path; a test pins that one
  call site produces one finding, because a duplicated warning is how a
  census stops being read.
- **The walk never reports a line.** `lint/scan` emits byte offsets;
  `lint` converts them in one merged pass over the file's line index.
  Keeping the conversion out of the walk is what keeps the walk pure of
  string handling and linear rather than quadratic.
- **`lint/cli` is the only module that does I/O.** `lint`, `lint/scan`,
  `lint/policy`, `lint/finding` and `lint/source` are pure functions of
  their arguments.
- **Findings are advice, never authority.** Nothing in the harness may
  import this package, and no rule here may be cited as a security
  control; `codemode/vet` is the security control.

## Deep Docs

- `lint` module doc — why the tool exists, the staging decision, and the
  totality claim in full.
- `lint/scan` module doc — the walk, and `cheap`'s doc comment, which is
  where R1's predicate is argued.
- `lint/portable` module doc — R6 in full: the two properties, what
  portable does not mean, why the rule names no target, and why each half
  looks where it does.
- `lint/layout` module doc — R10 and R11 in full: why a rule about blank
  lines exists, why layout cannot live in `lint/scan`, why the walk emits
  offsets and asks about lines afterwards, and what the two rules
  deliberately do not look at. `Weight`'s doc comment is where the `use`
  narrowing is argued.
- `lint/source` module doc — why the R4 backstop scans tokens rather
  than text, and why the line classification lives beside the offsets.
- `scripts/lint.sh` — the wrapper, the `# <errors> <warnings>` contract,
  and how to promote a rule.