Turns a script's code into a loaded module tree, or into a refusal that names what is wrong with it.
The parse comes first, and nothing compiles until it passes
A script's modules are VM-global. defmodule Enum in a script would replace
the standard library's Enum for the whole node, and defmodule YmerNode.Repo would replace the node's own — for every process, immediately,
with no way back short of a restart. So the code is parsed before it is
compiled and refused unless every defmodule the parse can see sits under
the script's own Script.<Name> subtree. Code.string_to_quoted/2 runs no
user code, so the refusal costs nothing.
What it can see is syntax, and that is the limit to state plainly: an alias
that rebinds Script, a defmodule wrapped in another form, a qualified
Kernel.defmodule/2 and a Module.create/3 call in a module body are all
outside it — and a module body runs while it compiles, so compiling code is
running it whatever a parse saw. This guard catches the honest mistake before
any compile; the boundary against a hostile author is which code is allowed
to compile at all, which is script_author's approval and acceptance
(YmerNode's Scripts section).
Three shapes are refused there, and each was measured rather than reasoned about:
- a top-level
defmoduleoutsideScript.— including baredefmodule Script, and a three-segmentScript.Jira.Clientas the top module; - a
defmodule Elixir.Anythinganywhere, nested included. This is the escape: nesting it insideScript.Hexdoes not scope it, and compiling that string really does define a top-level module; - a
defmodulewhose name is not a literal alias —unquote(name),@attribute,Module.concat(…)— because a name computed at compile time is a name this check cannot read.
The name is derived, never given: Macro.underscore/1 over the top
module's last segment, so the row's name, the VM module tree and the
references source token are one identity and a unique name implies a unique
tree.
Compiling
The previous tree is purged first. Recompiling a loaded module without purging
is not an error — it is two redefining module warnings that would ride
into the row's diagnostics and read as the script's fault.
The compile file name is script:<name>, which is what makes a run's stack
frames legible: a raise inside a script renders as script:hex:3: Script.Hex.go/0
rather than naming a file nobody can open.
The contract is checked here, not by the compiler
A behaviour's missing callback is a warning. A script that uses
YmerNode.Script and never defines run/3 compiles, loads, and fails at the
first call. So every callback is checked with function_exported?/3 after the
compile, and a module carrying no __script_contract__/0 is refused as not
having used the contract at all.
The three metadata callbacks are then called — that is user code running
inside a write — so the call is bounded by its own task and timeout. A
description/0 that loops would otherwise hang a create, and at boot it
would hang the node's start.
The compile itself is bounded too
A defmodule body runs its top-level statements while it compiles, so
defmodule Script.Boom do Process.sleep(:infinity) end — or an accidental
loop written outside a function — never returns from
Code.compile_string/2. try/rescue catches a raise and nothing else, so the
compile gets its own task and its own deadline, and a compile that outstays it
is killed and refused as :compile_timeout.
The bound is separate from the metadata one and larger: a real module's compile-time work — deriving protocols, building a big literal — can legitimately take longer than three callbacks that must answer immediately. It matters more than a caller's patience: this call happens inside the one process that owns every compile, so a compile that never returned would wedge every later read and write, and at boot it would hang the node's start with no log line and no way in.
Summary
Functions
Parses, purges the previous tree, compiles, and reads the contract.
Reads a script's identity out of its code without compiling any of it.
Unloads a script's whole module tree — the top module and everything under it that is loaded right now.
Renders compile diagnostics as one line each — script:hex:3:5: message —
which is what describe shows and what a refusal carries.
Functions
Parses, purges the previous tree, compiles, and reads the contract.
On success answers {:ok, map} carrying name, module, every modules
the code defined, the contract version, and the description, actions and
declarations the module reports, plus any compile warnings as rendered
strings. Every failure is {:error, {reason, detail}}.
Reads a script's identity out of its code without compiling any of it.
Answers {:ok, %{name: name, module: module}}, or {:error, {reason, detail}}
where reason names the rule the code broke.
Unloads a script's whole module tree — the top module and everything under it that is loaded right now.
Scanning the loaded set rather than trusting a remembered list is deliberate: the list can be stale (a previous compile in this VM that nothing recorded), and a module left loaded is one a later compile would warn about redefining.
Renders compile diagnostics as one line each — script:hex:3:5: message —
which is what describe shows and what a refusal carries.