Compile-time capability linter for node bodies.
A node's pure_core and effect_shell are ordinary Elixir, which means
nothing in the language stops them from calling File.write!/2,
System.cmd/2, :httpc.request/4, or Req.get/1 directly — sidestepping the
declared-capability facade (ctx.effects.*) entirely. This pass closes that
gap: it walks the clause bodies and rejects, at compile time, any call that
reaches the world outside the facade, so the facade is the only legal path
to IO.
What is allowed
A remote call M.f(...) in a node body is permitted iff M is:
- the effect facade (
Bloccs.Effects.HTTP/DB/Time/Random) —effect_shellonly;pure_coremust be side-effect-free, so the facade is rejected there; - a curated, known-pure stdlib module (
Enum,Map,String,Date, …); Applicationfor read-only config (its mutators are denied);- a module in this node's own OTP application (resolves local helpers), or sharing the node module's top-level namespace;
- a module explicitly listed in the manifest's
[lint] allow.
Everything else world-touching is a CompileError. Dynamic dispatch
(apply/3, a call on a variable module, captures of denied modules) is
rejected because it is not statically analyzable. Process/messaging escapes
(spawn, send, receive, Process.*, Task.*) are rejected — concurrency
is the generated supervision tree's job, not a node body's.
Honest scope (the TCB)
The trusted computing base is this pass plus the effect adapters. Within it the
capability declaration is unbypassable for the restricted module surface. It is
not a soundness proof: a too-permissive allow entry, a node calling its own
app's Repo directly (allowed by rule 4), or a compromised dependency at
runtime are out of scope. Runtime isolation remains OTP's job.
Opting out
[lint] effects = "off" downgrades violations to a single loud warning so a
node with a vetted exception still compiles; the opt-out is recorded on the
manifest and surfaced by tooling so the residual review stays visible.
Summary
Functions
Classify a single call target module.fun under ctx (%{kind, app, ns, allow}). Shared by the intraprocedural walker (this module) and the transitive
call-graph pass (Bloccs.Lint.Transitive).
Build the classification context the transitive pass passes to classify/3:
%{kind, app, ns, allow}. (The intraprocedural walker builds its own, which
additionally carries :env for alias expansion.)
The set of effect-facade modules (permitted only in effect_shell).
Lint the clause bodies of one node function. kind is :pure_core or
:effect_shell. lint is the manifest [lint] config (or nil → enforced
with no extra allows). Raises CompileError on a violation when enforcing;
emits a warning when opted out; returns :ok otherwise.
Functions
Classify a single call target module.fun under ctx (%{kind, app, ns, allow}). Shared by the intraprocedural walker (this module) and the transitive
call-graph pass (Bloccs.Lint.Transitive).
:ok— permitted and terminal (facade, pure stdlib, config, explicit allow, exception callback).{:follow, module}— a same-application / same-namespace module: permitted for a directly-written call, but a target the transitive pass must traverse.{:deny, reason}— a capability violation.
@spec context( :pure_core | :effect_shell, module(), atom(), Bloccs.Manifest.Lint.t() | nil ) :: map()
Build the classification context the transitive pass passes to classify/3:
%{kind, app, ns, allow}. (The intraprocedural walker builds its own, which
additionally carries :env for alias expansion.)
The set of effect-facade modules (permitted only in effect_shell).
@spec lint( Macro.Env.t(), :pure_core | :effect_shell, [Macro.t()], Bloccs.Manifest.Lint.t() | nil ) :: :ok
Lint the clause bodies of one node function. kind is :pure_core or
:effect_shell. lint is the manifest [lint] config (or nil → enforced
with no extra allows). Raises CompileError on a violation when enforcing;
emits a warning when opted out; returns :ok otherwise.