graded

Effect checker for Gleam via sidecar .graded annotation files.

graded verifies that your Gleam functions respect their declared effect budgets. Annotations live in .graded sidecar files alongside your source — your Gleam code stays clean.

Usage

gleam run -m graded check [directory]         # enforce check annotations (default)
gleam run -m graded infer [directory]         # infer and write effect annotations
gleam run -m graded infer --dry-run [dir]     # 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
gleam run -m graded why <name> [directory]    # explain a function's effects
gleam run -m graded catalog                   # list the bundled catalog files
gleam run -m graded catalog <package>         # print the catalog file selected for it
gleam run -m graded catalog <package>@<ver>   # print exactly that bundled file
gleam run -m graded format [directory]        # normalize .graded file formatting

Programmatic API

Use run to check a directory and get back a list of CheckResult values, each containing any violations found per file. Use run_infer to infer effects and write .graded files, or run_infer_dry_run to get back a diff of what that write would change without performing it. Use run_effect to resolve one function or type-field name and get its .graded line back, run_why to get the effects of one function explained call by call, or run_catalog to read graded’s own bundled catalog — none of them touching anything on disk.

Types

Why graded catalog could not answer.

pub type CatalogProblem {
  NoCatalogEntry(package: String)
  NoBundledVersion(
    package: String,
    requested: String,
    bundled: List(String),
  )
  NotInstalled(package: String, bundled: List(String))
  NoManifest(path: String)
  EmptyCatalog(directory: String)
  NoCatalogDirectory(candidates: List(String))
}

Constructors

  • NoCatalogEntry(package: String)

    No bundled file names this package.

  • NoBundledVersion(
      package: String,
      requested: String,
      bundled: List(String),
    )

    The package is bundled, but not at the version asked for.

  • NotInstalled(package: String, bundled: List(String))

    The package is bundled but not in this project’s manifest.

  • NoManifest(path: String)

    There is no manifest to read at the path the command consulted, or its TOML is malformed, so no package has an installed version to select on.

  • EmptyCatalog(directory: String)

    The catalog directory exists but holds no catalog file.

  • NoCatalogDirectory(candidates: List(String))

    None of the paths graded looks for its bundled catalog under exists.

Errors that can occur during checking, inference, or formatting.

pub type GradedError {
  DirectoryReadError(path: String, cause: simplifile.FileError)
  FileReadError(path: String, cause: simplifile.FileError)
  FileWriteError(path: String, cause: simplifile.FileError)
  DirectoryCreateError(path: String, cause: simplifile.FileError)
  GleamParseError(path: String, cause: glance.Error)
  GradedParseError(path: String, cause: @internal ParseError)
  InvalidConfig(path: String, cause: @internal ConfigError)
  FormatCheckFailed(paths: List(String))
  CyclicImports(modules: List(String))
  EffectNotFound(name: String)
  FunctionNotFound(name: String)
  PackError(message: String)
  CatalogError(problem: CatalogProblem)
}

Constructors

  • DirectoryReadError(path: String, cause: simplifile.FileError)

    Could not read the source directory.

  • FileReadError(path: String, cause: simplifile.FileError)

    Could not read a source or annotation file.

  • FileWriteError(path: String, cause: simplifile.FileError)

    Could not write an annotation file.

  • DirectoryCreateError(path: String, cause: simplifile.FileError)

    Could not create the output directory for annotation files.

  • GleamParseError(path: String, cause: glance.Error)

    A .gleam source file could not be parsed.

  • GradedParseError(path: String, cause: @internal ParseError)

    A .graded annotation file could not be parsed.

  • InvalidConfig(path: String, cause: @internal ConfigError)

    gleam.toml was present but malformed, or missing its name. A missing gleam.toml is tolerated and does not produce this error.

  • FormatCheckFailed(paths: List(String))

    One or more .graded files are not formatted (returned by run_format_check).

  • CyclicImports(modules: List(String))

    The project’s import graph contains a cycle. Gleam disallows circular imports at the language level, so this should be unreachable in practice — if it ever fires it indicates a bug in the dependency edge extraction rather than user code.

  • EffectNotFound(name: String)

    graded effect found no effect for the queried name: it names no public function and no declared type field.

  • FunctionNotFound(name: String)

    graded why found no function to explain: the name isn’t module-qualified, names no module of this project, or names no function of that module.

  • PackError(message: String)

    graded pack could not inject the spec into the hex tarball: the tarball was missing, its identity didn’t match the project, the configured spec_file path was unsafe, or the tarball transform failed.

  • CatalogError(problem: CatalogProblem)

    graded catalog could not answer: see CatalogProblem.

Values

pub fn infer_path_dep(
  dep_path: String,
  base_kb: @internal KnowledgeBase,
  consumer_modules: set.Set(String),
  package_targets: @internal PackageTargets,
) -> Result(
  #(
    dict.Dict(@internal QualifiedName, @internal EffectTerm),
    dict.Dict(@internal QualifiedName, List(@internal ParamBound)),
    dict.Dict(@internal QualifiedName, @internal EffectTerm),
    dict.Dict(@internal QualifiedName, @internal ReturnProvenance),
  ),
  Nil,
)

Build the dependency-graph index for a single path dep, topo-sort it, then infer every module in dependency order. Returns the union of all inferred effects, polymorphic param bounds, returned-operator signatures, and return-value provenance keyed by QualifiedName so the caller can fold them into the global knowledge base — the provenance lets a consumer resolve a computed-receiver call into the dep (dep.inner(dep.factory(x))) that a committed dep spec, which does not serialize provenance, cannot. Errors are swallowed (returned as Error(Nil)) to preserve the existing tolerance: a malformed dep shouldn’t break the whole project.

Exposed (pub) primarily so tests can exercise the topological-order path inference on a temporary directory tree without going through gleam.toml resolution. Production callers go through enrich_with_path_deps which reads gleam.toml to discover dep paths.

pub fn main() -> Nil
pub fn pack_project(
  project_root: String,
  tarball: option.Option(String),
) -> Result(String, GradedError)

Inject the configured .graded spec into project_root’s hex tarball. tarball overrides the default build/<name>-<version>.tar. Returns a success message (with the publish command), a GradedParseError when the spec does not parse — nothing a consumer would read is ever packed — or a PackError.

pub fn run(
  directory: String,
) -> Result(List(@internal CheckResult), GradedError)

Run the checker on all .gleam files in a directory.

Reads the project’s single spec file (default <package_name>.graded) to find inferred public-API effects, check invariants, external hints, and type field annotations, then reports violations per source file.

pub fn run_catalog(
  request: @internal CatalogRequest,
) -> Result(String, GradedError)

List the bundled catalog, or print one bundled catalog file.

ListCatalog prints one package@version line per bundled file, sorted, with a comment on the line each of this project’s installed packages resolves to. ShowCatalog prints one file: at the version this project installs, or at the version the request names, under a // header line that says which file it is and why — so the output is itself a valid .graded file.

This is graded’s bundled catalog alone: a dependency’s shipped spec, a path dependency’s spec and your own assume all override it, so run_effect is what answers which source wins for a name. Nothing is written to disk.

pub fn run_effect(
  directory: String,
  name: String,
) -> Result(String, GradedError)

Look up one name’s effect in directory’s project and render it as a .graded line.

name is either a module-qualified function (myapp/router.handle) or a type field (myapp/repo.Repo.find). Functions resolve from the spec file, dependencies, the catalog, and an in-memory inference pass, so a public function resolves without a prior graded infer; type fields resolve from declared field assume lines. Any provenance is appended as a // comment line, so the whole output parses as .graded syntax. Nothing is written to disk.

The CLI defaults to --format=prose for the person reading a terminal; this function keeps returning the parseable form, which is what a caller linking against the module wants. run_effect_formatted takes the format.

Returns EffectNotFound when the name is neither a public function nor a declared type field.

pub fn run_effect_formatted(
  directory: String,
  name: String,
  format: @internal Format,
) -> Result(String, GradedError)

Look up one name’s effect and render it in format: answer.Graded for the .graded line above, answer.Prose for sentences describing the same answer. Both render one structured answer, so they can differ in wording but never in what they report.

pub fn run_effect_from_project(
  directory: String,
  name: String,
) -> Result(String, GradedError)

Look up name the long way: assemble the whole project context — every module parsed, dependency sources scanned, girard run package-wide — and answer from its knowledge base, skipping the spec-only fast path run_effect tries first.

Exposed (pub) primarily so a test can assert the two paths agree. A fast-path answer is only correct if it is what the full context would have said, byte for byte; nothing else about the two is allowed to differ.

pub fn run_format(directory: String) -> Result(Nil, GradedError)

Format the project’s spec file in place. The spec file is the single source of truth for hand-written check/external/field assume lines and the inferred public-API effects.

pub fn run_format_check(
  directory: String,
) -> Result(Nil, GradedError)

Check that the project’s spec file is already formatted. Returns error with the file path if it isn’t. Used by CI as format --check.

pub fn run_format_stdin(
  input: String,
) -> Result(String, @internal ParseError)

Format a .graded spec given as a string, as graded format --stdin does for editor integration: parse the input, then sort and reformat it. Returns the input’s parse error if it doesn’t parse.

pub fn run_infer(directory: String) -> Result(Nil, GradedError)

Infer effects for all .gleam files in directory. Writes two outputs:

  1. Per-module cache files under <cache_dir>/<module_path>.graded, containing the inferred effects of every function in the module (public + private). Regenerated freely; not shipped.

  2. One spec file at <spec_file> containing the inferred effects of every public function across all modules, plus any hand-written check, assume, or type annotations the user already had in the spec file (those lines are preserved verbatim).

Walks the project’s import graph in topological order so each module is analysed after every other project module it imports — a single pass resolves transitive chains of any depth.

pub fn run_infer_dry_run(
  directory: String,
) -> Result(String, GradedError)

Preview what run_infer would change: a line diff of the spec file, or a message saying there is nothing to change. Runs the same inference run_infer does and writes nothing — neither the spec file nor the cache.

The diff’s old side is the spec file exactly as it sits on disk, so the re-rendering run_infer applies to every line it writes shows up in the preview too.

pub fn run_why(
  directory: String,
  name: String,
) -> Result(String, GradedError)

Explain where one function’s effects come from, as prose.

name is a module-qualified function in one of this project’s own modules (myapp/router.handle) — why re-walks a body, so there has to be one. Private functions are accepted: the walk is over source this project holds, unlike run_effect, which answers from the public knowledge base.

The output holds one block per check line declared for the function, each with that line’s own bounds fed to the analysis, in spec-file order — two check lines can substitute the same body differently, so neither block speaks for the other. With no check line there is one block, analysed with no bounds. A block states the function’s total effect, the check line it came from (informationally — the subset verdict is graded check’s), and one line per contributing call: what the call is, the effects it contributes, and either why they stayed unresolved or which source resolved them.

Contributors are the calls the checker reaches, not the call sites written in the body: a resolved call to a same-module function is replaced by that function’s own calls, so those surface instead, at spans inside it.

Returns FunctionNotFound when the name isn’t a function of a project module. Nothing is written to disk.

Search Document