Current section

Files

Jump to
graded README.md
Raw

README.md

# graded
[![Package Version](https://img.shields.io/hexpm/v/graded)](https://hex.pm/packages/graded)
[![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/graded/)
[![CI](https://github.com/alvivi/graded/actions/workflows/ci.yml/badge.svg)](https://github.com/alvivi/graded/actions/workflows/ci.yml)
> Effect checking for Gleam.
**graded** verifies that your Gleam functions respect their declared effect budgets. The tool reads and writes a single spec file at the root of your package — your Gleam source stays untouched.
## Quick start
```sh
gleam add --dev graded
```
Infer effects for your project:
```sh
gleam run -m graded infer
```
This scans `src/`, analyses every function, and writes two outputs:
- **`<package_name>.graded`** at the project root — the spec file. Contains the inferred effects of every *public* function plus any hand-written `check` invariants and `assume` declarations. Tracked in git.
- **`build/.graded/<module>.graded`** — per-module cache files. Contain the inferred effects of *every* function (public and private). Regenerated freely on each `graded infer` run, never shipped (`build/` is gitignored).
### Example
In a [Lustre](https://hexdocs.pm/lustre/) app, `view` must be pure — it builds HTML from the model without side effects. Enforce this with graded:
```gleam
// src/app.gleam
import gleam/io
import lustre/element.{type Element}
import lustre/element/html
pub fn view(model: Model) -> Element(Msg) {
io.println("rendering") // oops — side effect in view!
html.div([], [html.text(model.name)])
}
```
```
// app.graded — at the project root
check app.view : []
```
```sh
$ gleam run -m graded check
src/app.gleam: view calls gleam/io.println with effects [Stdout] (from gleam_stdlib's catalog entry) but declared []
graded: 1 violation(s) found
```
Remove the `io.println` and the check passes. Lustre's `init` and `update` functions are also pure — they return `#(Model, Effect(Msg))` where `Effect` is a data description, not an executed side effect.
Function names in the spec file are **module-qualified**: `app.view` means the `view` function in module `app`. Use slashes for nested module paths (`app/router.handle_request`).
## Configuration
graded reads its configuration from a `[tools.graded]` table in `gleam.toml`. Every field is optional — omit them to get the defaults.
```toml
[tools.graded]
spec_file = "myapp.graded" # default: "<package_name>.graded"
cache_dir = "build/.graded" # default: "build/.graded"
targets = ["erlang", "javascript"] # default: the top-level `target`, or "erlang"
```
`targets` decides which `@external` declarations graded treats as built. By default it follows `gleam.toml`'s top-level `target`; where that is absent too, graded reads Gleam fallback bodies on `erlang` — what the compiler builds when nothing says otherwise — while still reading every `@external` declaration on both targets, since a `--target` build is invisible to it. Set it if your package is really built for both: an `@external` declared for one target then has its Gleam fallback body reached on the other, and callers are charged both.
## Publishing your spec file to consumers
Gleam can't ship a package-root file like `myapp.graded` on a hex release — a published package includes `src/`, `gleam.toml`, the README, and the licence, with no configuration key to add more (a known Gleam limitation). The spec has to be injected into the release tarball after it's built, which is what `graded pack` does:
```sh
gleam export hex-tarball # build the release tarball
gleam run -m graded pack # inject <spec_file> into it, then publish as printed
```
`pack` places your spec at `build/packages/<your-package>/<spec_file>` in downstream projects — where graded's resolver already looks — so consumers need no setup. It patches `build/<name>-<version>.tar` in place (the `graded.pack_project` API also accepts an explicit tarball path) and prints the Hex publish API command to run next. Do **not** run `gleam publish` afterwards: it rebuilds the tarball from source and drops the injected spec. Documentation still publishes via `gleam docs publish`. The cache directory under `build/` is gitignored and never ships.
Two cases need no packing:
- **Path dependencies.** A `{ path = "..." }` dependency's root spec is read straight from its checkout.
- **Common packages.** graded bundles a catalogue of effect specs for popular packages, so many dependencies resolve with no spec of their own.
## Reference
The `.graded` spec language and graded's analysis model are documented in full in **[the Reference](https://hexdocs.pm/graded/reference.html)** — the annotation kinds (`effects`, `check`, `assume`) and the `where returns` clause, effect-set syntax, effect resolution order, higher-order and second-order effect polymorphism, type field effects, the effect-label conventions, and the bundled catalog of common packages.
## Commands
```sh
gleam run -m graded check [directory] # Enforce check annotations (default)
gleam run -m graded infer [directory] # Infer and write effects annotations
gleam run -m graded infer --dry-run [directory] # Preview the spec changes, writing nothing
gleam run -m graded effect <name> [directory] # Look up one effect, writing nothing
gleam run -m graded effect <name> --format=graded # ... as a .graded line instead of prose
gleam run -m graded why <name> [directory] # Explain a function's effects, writing nothing
gleam run -m graded catalog # List graded's bundled catalog files
gleam run -m graded catalog <package> # Print the catalog file selected for <package>
gleam run -m graded catalog <package>@<version> # Print exactly that bundled catalog file
gleam run -m graded format [directory] # Normalize .graded file formatting
gleam run -m graded format --check [directory] # Verify formatting (CI mode)
gleam run -m graded format --stdin # Format from stdin (editor integration)
gleam run -m graded -- --help # Show usage (-- passes the flag through gleam run)
gleam run -m graded -- --version # Show the installed version
```
An unknown command or option is a usage error, not a silently-checked directory.
`effect` answers a single lookup and writes nothing — the spec file and the cache are left untouched. Its `<name>` is either a module-qualified function (`myapp/router.handle`) or a type field (`myapp/repo.Repo.find`). It prints prose by default (`myapp/router.handle has effects [Stdout]`), describing where a higher-order function's effects come from and what its bounds assume, and stating a `[Unknown]` result as a name that was found whose effects weren't determined. `--format=graded` prints the same answer as a `.graded` line with provenance on a `//` comment, so it parses back — the format to pipe into a spec file. Public functions resolve without a prior `graded infer`; private functions and undeclared type fields report that the name wasn't found. A module covered by a module-level `assume <module>` declaration is the exception: that declaration answers for every name in the module that nothing else keys, so such a name resolves to the declared effect whether or not it exists.
`why` explains one function instead of answering for one name: it re-walks the function's body and prints a line per effect contributor — what the call is, the effects it contributes, and either why they stayed unresolved or which source resolved them, in the same wording violations use. It explains a function whether or not it has a `check` line and whether or not it fits one, so an effect you didn't expect is traced without first writing a budget to make it fail. Its `<name>` is a module-qualified function of one of your own modules (private ones included, unlike `effect`); a dependency function has no body here to walk. A function with two `check` lines gets one block per line, each analysed under that line's own bounds. Nothing is written.
`infer --dry-run` previews the same inference as a line diff of the spec file — the `-`/`+` lines with a couple of lines of context around them, or `graded: no changes` — and writes nothing, neither the spec file nor the cache. It exits 0 either way; `format --check` is the CI gate.
`check` and `infer` scope to the passed directory (default `src/`), recursing into it but never into `build/`. Passing the package root — `graded check .` — scopes to the root's `src/`, so module names come out as they appear in `import` statements (`app`, not `src/app`). A directory *inside* a package's `src/` narrows what is reported, not what is analysed: the whole package is resolved (module paths, imports and `@external` discovery are package-wide facts) and `check` reports only the passed subtree's files, while `infer`, which writes one package-level spec, writes the whole package's. To check another project, run graded from that project's root or point it at its `src/`.
## Limitations
graded is **sound, not complete**: it combines syntax-level analysis ([glance](https://hexdocs.pm/glance/)) with type information ([girard](https://hexdocs.pm/girard)), and when it can't statically trace a function value it falls back to the `[Unknown]` effect rather than guess. `[Unknown]` fails an effect budget, so graded never silently *understates* effects — but a few value-flow patterns need a hand-written annotation or a wider budget to resolve.
Idiomatic Gleam — inline callbacks, direct and aliased function references, pipe chains, higher-order functions passing functions by name (including second-order [operator effects](https://github.com/alvivi/graded/blob/main/docs/SECOND_ORDER_EFFECTS.md)), and validator/handler/config records — is handled automatically, including across modules: a fresh checkout resolves transitive chains with no prior `graded infer` (committed `effects` lines always win, and `check` writes nothing to disk).
The handful of patterns that fall back to `[Unknown]` — each with how it shows up and how to work around it — are documented in **[Limitations](https://hexdocs.pm/graded/limitations.html)**.
## License
Apache-2.0