Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
0.10.1 - 2026-06-26
Fixed
- A function-typed field on a dependency-defined type now resolves to its declared effect instead of
[Unknown]. When a receiver is typed by a dependency (fn use(repo: dep/repo.Repo) { repo.find(..) }), graded reads the dependency’s source — a path dependency at its declared location, an installed dependency underbuild/packages— to type the receiver, so a module-qualifiedtype dep/repo.Repo.find : [Storage]line resolves at the call site. Previously the receiver type was unresolved for path dependencies, and for installed dependencies whenever graded ran outside the package root, so the field call leaked[Unknown]. - A dependency now ships the effects of its own types’ function fields.
typefield annotations in a dependency’s spec file, and in the versioned catalog, are loaded into the knowledge base alongside itseffects/externalentries, so a consumer resolvesreceiver.field(..)on a dependency-defined record without re-declaring it. A consumer’s owntypeline wins on a clash; otherwise the priority follows the effect order (path dependency > installed dependency > catalog).
0.10.0 - 2026-06-25
Added
- Lustre 5 catalog. A
lustre@5.0.0.gradedfile covers the v5 surface — pure constructors (application,simple,component,element, …), the effectful runtime (start,register,send, …), and the element/attribute/event submodules. Lustre 5.x projects select it; 4.x projects keeplustre@4.0.0.graded. graded checkwarns on spec lines that match nothing. Acheckline whose name matches no project function (usually a missing module qualifier) never runs and passed silently — now flagged. Atypeline is flagged when it resolves no field: unqualified, pointing at an unknown module, or naming a non-function field. Callability follows type-alias chains across project and dependency modules. Fields on dependency-owned or unresolvable types are left alone. All warnings report against the spec file.- Function-typed record fields resolve polymorphically instead of
[Unknown]. Afn-typed field on a receiver with no traceable construction site (an opaque parameter) becomes a field-effect variable named for itsreceiver.fieldpath. It discharges against a field bound (check f(r.run: [Stdout]) : [..]) or atype myapp.Runner.run : [Stdout]line, and surfaces as a polymorphic bound when inferred (effects f(r.run: [r.run]) : [r.run]); left unbound it concretizes to[Unknown], never silently[]. Resolution follows the receiver’s inferred type, so it covers nested receivers (model.service.org.create(..)) and nested pipe targets (value |> o.inner.run). Fields declared through a module-local function alias are recognized as callable.
Fixed
- A module-level
external effects <module>declaration now governs a path dependency’s inferred module, with its full effect set. Previously the declared set was flattened to pure (sodep.*calls resolved to[]), and graded’s own source-inference of the path dependency shadowed the declaration — leaving the module and its in-dependency callers at[Unknown]. The declaration now applies during the dependency’s inference, so both resolve to the declared set. A per-functionexternal effects mod.fnor an authoritative dependency spec/catalog effect still takes precedence. - A module-level external now governs the consumer’s own project modules too. A declaration for a project module (
external effects myapp/db : [Database]) was shadowed by graded’s in-memory inference of that module’s source, so its functions resolved to the inferred effect rather than the declared set. The inferred call effect is now dropped for a declared module — at bothcheckandinfertime, so a sibling module calling into it agrees — andgraded inferwrites no per-functioneffectslines for it, matching how a per-function external suppresses its own line. Returned-operator and parameter-bound metadata are kept.
0.9.4 - 2026-06-24
Fixed
- A closure passed to a second-order parameter now keeps its captured callable bindings. When a closure is lifted over an operator parameter (
with(fn(callback) { suffix("a", "b"); callback("hi") })), its body is re-analysed away from where it was written; a name bound there (let suffix = string.append) was no longer in scope, so it resolved to[Unknown]and the closure’s effect came out as[Stdout, Unknown]instead of[Stdout]. The closure now carries the callable bindings in scope at its creation site — a qualified alias, a let-bound closure, acase-of-closures, or a returned operator — so re-analysis resolves each to its precise effect. The capture respects shadowing (the binding visible where the closure was written wins) and excludes the closure’s own parameters. The earlier direct-call fix already resolved captures for a closure applied by name; this extends the same precision to one passed to a higher-order function. - Expression-valued callees are no longer inferred pure. An immediately invoked closure (
fn(cb) { cb("x") }(io.println)), an applied returned function (printer()("x")), or acase/ifthat selects the function being called now propagates the callee’s effect. Previously graded walked the callee as a value without modelling the application, so effectful code could be inferred as[]and slip past acheck ... : []purity invariant. An opaque computed callee (funcs.0(x)) now resolves to[Unknown]rather than[]. - A parameter that shadows an unqualified import now resolves to the parameter, not the import. With
import gleam/string.{uppercase}, a functionrun(uppercase: fn(String) -> String)that calls or forwardsuppercasebinds to its parameter. Previously the import shadowed the parameter, so an effectful argument passed for a parameter named like a pure import could be inferred pure. - A let-bound closure that is called directly (
let helper = fn(x) { ... }; helper(1)) now resolves to its body’s effect instead of[Unknown]. The extractor tracked the binding and resolved it when the closure was passed to a higher-order parameter, but a direct application by name fell through to an unresolved local call, so the common idiom of defining a reusable builder aslet row = fn(...) { ... }and mapping it over a list cascaded to[Unknown]and blocked acheck view : []invariant. The closure body is analysed at its binding site, where the lexical environment resolves any captured callable (let suffix = string.append; let h = fn(x) { suffix(x) }), and the direct application adds the effect of each argument the closure actually invokes; a directly-appliedcase-of-functions resolves the same way. - More higher-order closure patterns resolve to a precise effect instead of
[Unknown]: a callback closure with an ordinary value parameter (fn(message) { io.println(message) }); a callback that ignores a higher-order parameter (fn(_next) { … }); a producer whose returned closure captures or applies a first-order callback parameter; and an immediately invoked closure with more than one argument (every argument is applied, not only the first). - An immediate application of a returned function (
make(io.println)()) no longer drops the producer’s arguments. An internal effect variable that can never be bound at a call site now collapses to the conservative[Unknown]instead of leaking into the inferred effect set.
0.9.3 - 2026-06-23
Fixed
- A same-module (unqualified) call into a bodyless
@externalnow applies itsexternal effectsdeclaration, matching the cross-module (qualified) call path. The local-call path resolved opaque externals straight to[Unknown]without consulting the knowledge base, so a declaredexternal effectsentry took effect only for callers in other modules; the common FFI idiom of an@externalbinding plus a same-module wrapper cascaded to[Unknown]. Undeclared externals still resolve to[Unknown].
0.9.2 - 2026-06-23
Fixed
- Record update expressions (
Rec(..base, field: expr)) now have their updated field values walked, so effects in those expressions are counted. Previously only the base record was extracted, under-approximating the effect set and letting acheck ... : []pass over a record update whose field called an effectful function. - Dependency, catalog, and path-dependency resolution now read from the checked project’s own root (
build/packages,manifest.toml, and the path-depgleam.toml), found by walking up from the source directory to the nearestgleam.toml. Previously these paths were resolved relative to the process working directory, so checking a project from a different directory loaded the wrong dependency specs, installed versions, or path dependencies. - A higher-order function defined in a path dependency now discharges its callback parameter’s effect at the call site, instead of leaking the parameter’s effect variable (e.g.
[on_change]) into the caller. Path dependencies are loaded through a separate code path that recorded each callee’s effects but dropped its polymorphic parameter bounds, and never registered the dep’s parameter signatures — so neither labelled nor positional callback arguments could be matched. The path-dep loaders now thread parameter bounds and returned-operator signatures into the knowledge base and register path-dep signatures, reaching parity withbuild/packagesdependencies. The identical function defined in-project was already handled. - graded now compiles and runs on the JavaScript target by providing JavaScript externals for the built-in FFI used by
format --stdinand process halting.
0.9.1 - 2026-06-23
Added
- Added catalog entries for the pure value libraries
bigi,glearray,iv, andgleam_community_maths— calls into them now resolve to[]instead of[Unknown].
Fixed
- A higher-order callback passed with a Gleam label (
apply(with: parser)) now binds to its parameter, so the parameter’s effect variable resolves instead of leaking into the fully-applied caller. Previously only positional callback arguments were matched; a labelled call site left the variable unresolved (e.g.[parser]).
0.9.0 - 2026-06-22
Added
- Field bounds. A
checkline can bound a function-typed field reached through a parameter, using aparam.fieldpath:check myapp.view(handler.on_click: [Dom]) : [Dom]. The field call resolves to the declared effect, taking priority over receiver-type resolution — the boundary-scoped counterpart to atypeline, for a receiver graded can’t trace to a construction site. - A field bound whose
param.fieldpath matches no field call in the checked function’s body now emits a warning, catching typos in the path that would otherwise resolve nothing silently. When the receiver is not a parameter, the warning also notes the call may have resolved through value provenance, which shadows the bound, rather than blaming the path. - A plain parameter bound whose name matches no declared parameter now emits a warning. It is matched on parameter existence, not call presence, so a callback that’s forwarded but never called directly is not flagged.
Fixed
gleam/time/calendar.utc_offsetis now[]instead of[Time]. It is a compile-time constant (duration.empty), not a clock or timezone read, so it carries no effect.calendar.local_offsetandtimestamp.system_timeremain[Time].- A same-module named function passed to a first-order fn-typed parameter now resolves to its actual effect instead of
[Unknown].parse_optional("x", logging_parser)binds the parameter tologging_parser’s effect — so a fully-applied caller is no longer polluted by an unresolved effect variable from a higher-order callee. Inline closures already resolved; named references now take the same lift-and-discharge path operator arguments have used since 0.7.0.
0.8.1 - 2026-06-22
Changed
- Dropped the
stdinandgleam_yielderdependencies.graded format --stdinnow reads standard input through a small built-in Erlang FFI. Thestdinpackage cappedgleam_stdlibbelow1.0.0, which made graded uninstallable alongside packages that requiregleam_stdlib >= 1.0.0.
0.8.0 - 2026-06-21
Added
- Catalog entries for 27 more of the most-used Gleam packages. Calls into these now resolve to a precise effect (or
[]for pure libraries) instead of[Unknown]: glance, glexer, justin, snag, ranger, marceau, gleam_community_colour, gleam_community_ansi, glam, splitter, gleam_bitwise, gleam_javascript, and gleam_deque (pure); glisten, mist, wisp, pog, gleam_fetch, gleam_hackney, gleam_cowboy, gleam_elli, shellout, logging, argv, directories, birl, and youid (effectful). - The catalog now covers all of the core
gleam-langruntime, data, and HTTP packages. The two remaining official packages,gleam_package_interfaceandgleam_hexpm, are tooling libraries and stay uncatalogued for now. - New effect labels
Network,Database,Exec, andRandomfor socket/server I/O (glisten, mist), database queries (pog), running external programs (shellout), and nondeterministic generation (youid v4/v7,wisp.random_string).
Fixed
- When a function appears in both an installed dependency’s spec file and the bundled catalog, its effects now come from the dependency’s spec file.
- Effects performed inside
panic/todo/echomessages and bit-string segments are now counted toward a function’s effects. graded formatandgraded format --checknow report an error on a.gradedspec file that cannot be parsed, instead of succeeding silently. A missing spec file is still treated as nothing to do.- A malformed
gleam.tomlis now reported as an error instead of being silently ignored. A missinggleam.tomlstill falls back to defaults.
0.7.0 - 2026-06-19
Added
- Second-order (higher-kinded) effect variables. The effect representation moved from a flat
Polymorphic(labels, variables)set to anEffectTerm(a lambda-calculus-with-union), letting graded express and resolve effect variables of kindEff → Eff(operators), not just flatEff.- An operator parameter (one whose type takes functions,
action: fn(fn() -> Nil) -> a) infers a curried application[action([Stdout], [FileSystem])]over every callback, in order. - At a call site, operator arguments beta-reduce to concrete effects. Named refs, inline/let-bound closures,
case/ifbranches (joined per-branch), and operators returned from calls are all lifted. - Same-module named functions passed as operator arguments resolve transitively instead of collapsing to
[Unknown]. - The
.gradedsyntax gained operator applications and operator bounds (fn(a, b) -> [a, b]); first-order lines are byte-identical to before.
- An operator parameter (one whose type takes functions,
- Resolution is pure-Gleam term reduction (capture-avoiding substitution, beta, union normalization, fuel-guarded), no external solver. Laws, soundness, and termination are property-tested with qcheck. See docs/SECOND_ORDER_EFFECTS.md.
- More value flow resolves instead of
[Unknown].- Blocks resolve to their tail — a block value (
{ let f = io.println; f }) is classified by the expression it evaluates to. - Returned operators cross modules and packages via
returns mod.fn : fn(cb) -> [cb]lines, socheckresolveslet h = producer(); with(h)across boundaries. - Record fields wired to an inline closure infer the field’s effect from the closure body, no
typeannotation needed. checkauto-infers project modules missing from the spec (in memory, topological order); committedeffectslines still win and nothing is written to disk.- Operator-typed record fields — a field wired to a closure calling its own callback (
Middleware(wrap: fn(next) { next() })) is lifted to an operator and applied at the field call. - Return-effect polymorphism — a producer that returns or wraps an operator parameter (a decorator) resolves, binding the parameter to the call’s argument. Returned closures are lazy, so they’re excluded from the producer’s own direct effect.
- Blocks resolve to their tail — a block value (
Environmenteffect + envoy catalog entry. Process env-var access is now a first-class effect viapriv/catalog/envoy@1.0.0.graded, mappingenvoy.get/set/unset/allto[Environment]instead of[Unknown].
Fixed
@external(FFI) functions are now[Unknown]by default. Foreign code is opaque, so an@externalfunction infers[Unknown]instead of the[]an empty or fallback body would yield — even with a Gleam fallback, since it only runs on the other compile target. Opt into a precise effect withexternal effects mod.fn : [...](or the catalog), which wins at resolution and drops the inferred line.- Field calls on a record built at several construction sites no longer leak operator bounds. A function-typed field gets a union of operators (one per construction site); the resolver previously returned it raw, leaking bounds into first-order callers. The union is now applied to the call’s arguments and distributes (
(L ⊔ f ⊔ g)(args) = L ⊔ f(args) ⊔ g(args)). Always sound, but the leaked bounds weren’t round-trip parseable. inferno longer hangs on densely mutually-recursive modules. Per-callee body analysis is now memoized per module, and the call graph is partitioned into SCCs (Tarjan’s): first-order components collapse to one shared effect set, polymorphic callees are keyed by name plus same-component ancestors. First-orderness is decided syntactically (not via the best-effort type annotator) for stable results. Results unchanged — only speed: three corpus packages that timed out now infer in 1–5s.
Notes
- Remaining residuals (all sound, collapsing to
[Unknown]): a parameter selected through a branch, a field wired to a constructor parameter, a function reached through arbitrary computation (handlers |> list.first |> unwrap), ause-tailed return, and external/FFI code. Annotate explicitly where needed.
0.6.0 - 2026-04-21
Added
- Same-function value flow. graded now tracks three kinds of local
letbindings inside a function body and resolves calls through them:- Function-ref aliases.
let f = io.println; f("hi")resolves togleam/io.printlninstead of being treated as a local call. Transitive aliases (let g = f) resolve through the chain. - Record construction.
let v = Validator(to_error: io.println); v.to_error("oops")resolves the field call toio.printlndirectly — no per-type annotation needed for the common case of local construction. Both labelled (Validator(to_error: ...)) and positional (Validator(...)) construction work for same-module constructors; positional arguments are mapped to the constructor’s declared labels. - Shadowing. Later
lets correctly shadow earlier bindings; unrecognisable RHS expressions erase tracking so stale bindings don’t leak forward.
- Function-ref aliases.
- Block and closure bodies inherit the outer env but their own bindings don’t leak out, matching Gleam’s scoping.
Notes
- Cross-function record construction (passing a record built in one function to another) remains opaque and still needs type-level annotations (
type myapp.Foo.field : [...]). Pattern destructuring anduse-bound names are deliberately treated as opaque.
0.5.0 - 2026-04-13
Added
-
Effect polymorphism. Effect variables (lowercase tokens inside brackets) let one signature express that a function propagates whatever effects its callback has:
effects myapp/validation.validate_range(to_error: [e]) : [e] effects myapp.map_with_log(f: [e]) : [Stdout, e]graded inferproduces polymorphic signatures automatically when a function calls a parameter annotated with afn(...) -> ...type. The variable is named after the parameter. -
Call-site substitution. At each call site, effect variables bind to the concrete effects of the argument passed: a function reference resolves via the knowledge base, a type constructor is pure, the caller’s own bounded parameter uses that bound’s effects, and anything else falls back to
[Unknown]. Works with both labeled (validate_range(42, to_error: OutOfRange)) and positional (validate_range(42, OutOfRange)) arguments. Covers cross-module calls, same-module local helpers, and calls into dependencies. -
Dependency parameter positions. graded now parses each
build/packages/<dep>/src/tree with glance to learn dependency function signatures. Positional arguments to polymorphic dep functions resolve correctly without requiring labels. -
Wildcard
[_]. Documented in the README’s new Effect set syntax section. Wildcard is the top of the effect lattice —[_]as a declared budget permits any effects. Useful for entrypoints.
Changed
- Violation messages now include a hint when the actual effects contain unresolved effect variables, suggesting a
checkbound or a concrete argument to bind against.
0.4.2 - 2026-04-12
Fixed
- Added
gleam/dynamic/decodeto thegleam_stdlibcatalog. Decoder combinators (field,optional_field,string,int,list,dict,success, etc.) are pure but were resolving as[Unknown]. graded infernow resolves cross-module type constructors as pure, matching the existing handling for unqualified constructors. Previously, calls liketypes.NotFound(id)from a sibling project module were marked[Unknown]because constructors aren’t tracked in the knowledge base and the defining project module isn’t inpure_modules. Constructors are pure by Gleam’s syntactic rules — an uppercase-initial label after a.is always a type variant — so the qualified call, qualified pipe target, and qualified value-position branches in the extractor now short-circuit the same way the unqualified path does. Side-effecting expressions inside a constructor’s argument list (e.g.NotFound(io.println(x))) still propagate.
0.4.1 - 2026-04-11
Fixed
graded infernow reads the spec file’sexternal effectsandtypefield declarations into the knowledge base before walking the import graph. Previously these were only consumed bygraded check, so functions calling into a third-party module declared pure viaexternal effectswere still inferred as[Unknown]. Thecheckpass passed but the inferred spec stayed noisy.
0.4.0 - 2026-04-10
Added
[tools.graded]config table ingleam.toml, withspec_fileandcache_dirfields.graded/internal/topomodule: standalone topological sort over a string-keyed dependency graph, with property and unit tests.
Changed
- Project annotations have moved out of
priv/graded/. Each Gleam package now has a single spec file at the project root (default name<package_name>.graded, configurable via[tools.graded].spec_fileingleam.toml) holding the public-API effects,checkinvariants,external effectshints, andtypefield annotations. Per-module inferred effects (public + private) live inbuild/.graded/as a regenerable build cache (configurable via[tools.graded].cache_dir). Both locations are read bygraded checkand written bygraded infer. - Function names in the spec file use the module-qualified form:
myapp.view,myapp/router.handle_request. Slashes for the module path, dot before the function name (same convention asexternal effects). Cache files continue to use bare names because each one is implicitly scoped to a module by its file location. - Type field annotations gained the same qualification:
type myapp.Handler.on_click : [Dom]. The bare form (type Handler.on_click : [Dom]) remains valid in cache files. - Library authors who want their effect annotations to ship to consumers must add their spec file to
included_filesingleam.toml. Without this, downstream packages will not see the library’s effects (and will fall back to[Unknown]for its functions, unless the catalog covers them). - No automatic migration from the old layout. To migrate an existing project: move every
effects/check/external/typeline out ofpriv/graded/<module>.gradedinto<package_name>.gradedat the project root, prefixing each function name with its module path. Then rungraded inferand delete the oldpriv/graded/directory.
0.3.0 - 2026-04-07
Added
- Cross-module effect propagation: inferred effects from sibling project modules are used when analyzing other modules in the same project. Two-pass inference resolves inter-module dependencies.
0.2.0 - 2026-04-07
Added
- Catalog entry for
gleam_time(all modules pure;system_time,local_offset,utc_offsetmarked[Time]). - Catalog entry for
houdini(fully pure). - Automatic effect inference for path dependencies declared in
gleam.toml. Functions from local path deps are now inferred from source instead of being marked[Unknown]. - Path dependency inference loads existing
.gradedfiles for parameter bounds, improving accuracy for higher-order functions. - Two-pass inference for path dependencies so cross-dep calls resolve correctly.
Fixed
- Record constructors (
Ok,Error,Some, custom types) no longer inferred as[Unknown]. Gleam constructors start with an uppercase letter and are always pure.
0.1.0 - 2025-04-04
Added
- Effect checker for Gleam via sidecar
.gradedannotation files. graded checkcommand to enforcecheckannotations.graded infercommand to infer and writeeffectsannotations.graded formatcommand with--checkand--stdinmodes.- Higher-order effect tracking with parameter bounds.
- Field call effect tracking with type-aware resolution.
- External effect declarations for third-party functions.
- Wildcard effect
[_]as the universal top element. - Warnings for function references passed as values with known effects.
- Versioned catalog system resolved against
manifest.toml. - Catalog entries for
gleam_stdlib,gleam_erlang,gleam_otp,gleam_http,gleam_httpc,gleam_json,gleam_regexp,gleam_yielder,gleam_crypto,lustre,lustre_http,simplifile,filepath,tom.