:script Tier 2 – Top-Level Code, Auto-Imports, and the Script Law
:script Tier 2: Top-Level Code, Auto-Imports, and the Script Law
Section titled “:script Tier 2: Top-Level Code, Auto-Imports, and the Script Law”“Every
:scriptprogram is a syntactically valid:coreprogram afterjanus desugar. The transformation is purely additive. Promotion is mechanical. There is no second language hiding behind:script.” – SPEC-045 §0, The Script Law
This page covers what Tier 2 of the :script profile actually does for you, as of compiler version 2026.5.x.
Updated 2026-05-01 to reflect the SPEC-045 §3.1, §3.2, §3.4 implementations shipped during the Tier 2 sprint: pass-2 implicit
tryinjection, thejanus desugarCLI, the round-trip harness, and the OS-layered auto-import paths.
If you came here from the :script overview, this is the deep dive: how the magic actually works, and how to reason about your .jans file when something goes wrong.
The shape of a .jans file
Section titled “The shape of a .jans file”A :script file ends in .jans and looks like this:
let name = "World"println("Hello, {name}!")No func main. No use std.io. Just statements. Run it with:
janus run hello.jansThe compiler does four things at parse time, in order, before sema even runs:
- Top-level statements lift into a synthesized
main(SPEC-045 §3.1). - Implicit
try(...)is injected at every fallible call site in the synthesizedmain(SPEC-045 §3.2). - The auto-import set is prepended ahead of your code (SPEC-045 §3.4). The spec lists ten modules; seven emit today, three are filtered until SI-1/SI-2/SI-3 close.
- The result is canonical
:core– the same AST a hand-written.janfile would produce.
You can prove the fourth point at any time with janus desugar (read the source) or with janus validate --promotable (run the falsifiability check).
What the desugar actually produces
Section titled “What the desugar actually produces”Take the hello.jans above. After Tier 2 runs, the compiler is staring at an AST equivalent to this .jan file:
// hello.jan – the canonical :core form
use std.iouse std.os.fsuse std.os.processuse std.text.peguse std.collectionsuse std.fmtuse std.os.env
func main() : !void do let name = "World" println("Hello, {name}!")endTwo transformations, both purely additive:
- The fixed set of
uselines was prepended to your file by auto-import injection (SPEC-045 §3.4). - Your two statements were collected into a function body and wrapped in a synthesized
main. The synthesized function carries an internal#[script_main]tag that the compiler uses for diagnostics and for gating pass 2; nothing about it is hidden from you when you ask.
You never wrote either layer. You can read both at any time with janus desugar or by running janus validate --promotable and inspecting diagnostics.
Why a fixed list and not a configurable one? Because the list is frozen in the spec (SPEC-045 §3.4). It cannot be extended per-project, per-user, or per-file. If you want a tighter import set you have outgrown
:script– promote to:coreand write the imports you need.
The auto-imports (SPEC-045 §3.4)
Section titled “The auto-imports (SPEC-045 §3.4)”The spec lists ten modules. Three are currently filtered from emission via the auto_import_known_broken constant in compiler/desugar/script.zig because of pre-existing stdlib defects (SI-1/SI-2/SI-3). They will auto-emit when those issues close, without any spec change. The seven that emit today:
| Module | Status | What it gives you |
|---|---|---|
std.io | emits | print, println, eprintln, stdin, stdout |
std.os.fs | emits | read_file, write_file, glob, Path |
std.os.process | emits | spawn, the runtime backing shell literals |
std.text.peg | emits | Typed PEG grammars, Peg[T] |
std.collections | emits | Vec, Map, Set, Deque |
std.fmt | emits | The string interpolation runtime ($"...") |
std.os.env | emits | Env.get, Env.args |
std.text.rx | filtered (SI-1) | Compile-time regex literals (r/.../), Regex type |
std.text.stream | filtered (SI-2) | TextStream, grep, field, replace, … |
std.text.search | filtered (SI-3) | walk, search, ripgrep-grafted file walking |
The spec was corrected in S1 to point at the OS-layered facades: std.os.fs, std.os.process, std.os.env. The desugar emits these paths verbatim. There is no compatibility alias for the older flat names.
When your file already imports one of these by hand:
use std.io // explicit; perfectly fineprintln("hi")The compiler emits E3113_AUTO_IMPORT_SHADOWED as a warning (compile still succeeds). The warning exists so that when you eventually janus desugar your file into :core, you do not get a duplicate use std.io in the output. Resolve the warning by either:
- Removing the explicit
use std.io(the auto-import already covers it), or - Adding an alias:
use std.io as stdio(the compiler treats aliased imports as not-shadowing).
Note for Rust converts. Rust’s
use std::io::printlnreplaces the wildcard for that name. Janus’suse std.io.{println}does not – the auto-importedstd.iowildcard still resolves other members. This deliberate divergence preserves discoverability for:scriptusers (they can call anystd.io.*member without remembering which they imported) at the cost of breaking Rust muscle memory. Selective imports are an explicit-narrowing tool in:core; in:scriptthey are additive over the auto-import surface.
The three synthetic-main cases (SPEC-045 §3.1)
Section titled “The three synthetic-main cases (SPEC-045 §3.1)”Pass 1 (top-level desugar) recognises three input shapes. Knowing which shape your file is in tells you exactly what the compiler will do with it.
Case 1 – Top-level statements only (the principal :script case)
Section titled “Case 1 – Top-level statements only (the principal :script case)”let name = "World"println("Hello, {name}!")Pass 1 synthesizes func main() : !void, lifts every top-level statement into the body in source order, and tags the synthesized function with #[script_main]. This is the path most .jans files take.
Case 2 – User-defined func main, no top-level statements
Section titled “Case 2 – User-defined func main, no top-level statements”// hello.jans – the natural progression for a script that grew up
func main() : !void do let name = "World" println("Hello, {name}!")endPass 1 short-circuits: it does not synthesize a wrapper, it does not apply #[script_main]. The user’s func main is used as written. Auto-imports are still prepended. This is the natural progression for a .jans file that has outgrown free-form top-level use but is not yet ready (or not yet wanted) to be promoted to .jan. You get the explicit signature without surrendering the :script ergonomics.
Case 3 – Both func main AND top-level statements
Section titled “Case 3 – Both func main AND top-level statements”// ambiguous.jans – does not compile
let x = 42 // top-level statement
func main() : !void do println("x = {x}")endThis is E3102_USER_MAIN_WITH_TOP_LEVEL_STMTS. Pick one shape: either lift the top-level statement into main, or delete func main and let pass 1 synthesize one for you. The compiler refuses to guess which entry point you meant.
Pass 2 – Implicit try injection (SPEC-045 §3.2)
Section titled “Pass 2 – Implicit try injection (SPEC-045 §3.2)”Pass 2 runs after pass 1. It walks the synthesized main’s body (and only the synthesized one – the rule is gated on the #[script_main] tag from pass 1) and, at every call site whose callee returns a fallible type (!T), wraps the call in try(...) if the user did not already write one.
// before pass 2 – your .jans filelet contents = read_file("config.kdl")let parsed = parse(contents)println(parsed.title)
// after pass 2 – what sema seesfunc main() : !void do let contents = try(read_file("config.kdl")) let parsed = try(parse(contents)) println(parsed.title)endThis is the desugaring that makes the Two Implicit trys of the Script Law pay rent. You write the linear, error-propagating code that reads like Python; the compiler inserts the propagation machinery so a real :core programmer can read the lowered output and recognise :core semantics with no surprises.
Closure boundaries (Task 12 design §4.b)
Section titled “Closure boundaries (Task 12 design §4.b)”The walker stops at closure boundaries. Calls inside |x| -> ... lambdas, behavior-tree action thunks, or any function literal nested inside the synthesized main are not auto-tried by :script rules. That is intentional: a closure has its own return type, its own error policy, and may be invoked far from the synthesized main it textually appears in. The closure body follows :core rules. If the closure itself returns !T, the compiler still treats the call to the closure (in main’s body) as fallible and wraps it.
When pass 2 does NOT run
Section titled “When pass 2 does NOT run”- The file is in case 2 above (user wrote their own
func main). User-authoredmainis:coresemantics, full stop. No implicittry. - The call appears inside a closure. As above.
- The user already wrote
try(...)around the call. The walker is idempotent – a second wrapping never happens.
Single-expression escalate (SPEC-044 §3.2.1)
Section titled “Single-expression escalate (SPEC-044 §3.2.1)”When a :script body needs one capability from a higher profile, you don’t write a multi-line escalate block. The single-expression form keeps the cost label on the same line as the call:
// numerics – :compute capability for one linear solvelet model = escalate :compute lstsq(X, y)
// game script – :cluster capability inline in a behavior treebt.action(|| escalate :cluster spawn alert_others(self.pos))
// agent script – :service capability for one HTTP calllet answer = escalate :service llm.chat(prompt, tools: tools)Both forms are legal everywhere escalate is legal:
// Block form (multi-statement bodies)let r = escalate :compute do let scratch = allocate_buffer(1024) matmul(a, b, scratch)end
// Sugar form (single expression)let r = escalate :compute matmul(a, b)The sugar form drops four tokens (do, end, two whitespace runs) and zero audit signal. grep -nE 'escalate :[a-z]+' over your source still finds every escalation site in either form. The header-level escalations: declaration still bounds what your module is allowed to escalate to. The disambiguation is mechanical: do is reserved and cannot head an expression, so the parser knows which form you wrote without hints.
This is the principal mechanism by which the One Escalate Law stays cheap on the page: a single :compute solve, a single :cluster spawn, a single :service HTTP call all fit on one line without surrendering the cost label.
The janus desugar CLI
Section titled “The janus desugar CLI”janus desugar is the falsifiability tool for the Script Law. It runs pass 1 + pass 2 + auto-import injection on a .jans file and emits the canonical :core source – the exact text a hand-written .jan file would need to be in order to round-trip through the compiler with identical observable behaviour.
janus desugar hello.jans # writes :core source to stdoutjanus desugar hello.jans -o hello.jan # writes to file (refuses to overwrite)janus desugar hello.jans -o hello.jan -f # force-overwrite an existing targetThe printer follows the canonical-form rules in SPEC-045 §6.b. It does not reformat – it emits one mechanical shape per AST kind. Indentation, brace style, and blank-line policy are fixed.
.jan input is copied verbatim
Section titled “.jan input is copied verbatim”If you pass a .jan file (already in :core form), janus desugar writes it back unchanged. janus desugar is not a formatter. It does not run on Janus source for cosmetic purposes. There is a separate janus fmt ambition; that is not this tool.
The round-trip property
Section titled “The round-trip property”The Tier 2 sprint shipped a round-trip harness over 10 fixtures including the implicit-try case. The verified property is:
Building and running
<file>.jansproduces the same stdout as building and runningjanus desugar <file>.jans -o <tmp>.jan && janus run <tmp>.jan. Byte-for-byte.
This is what the Script Law actually means in the build system. janus desugar is not a documentation aid – it is the executable specification of the desugar.
Note for users (especially game-script designers and modders)
Section titled “Note for users (especially game-script designers and modders)”A // tuned 2026-04-30, ask Sven before changing comment in the .jans file does not survive janus desugar round-trip. Comments are dropped. This is scheduled to land with the Phase 1 sugar sprint backlog – not deferred indefinitely. Until that lands, do not treat janus desugar as a safe “promote in place” tool for files where the comments are doing operational or cultural work. The lowered AST has no comment nodes; the printer cannot reattach what it does not have.
Validating your script against the Script Law
Section titled “Validating your script against the Script Law”The Script Law says: every .jans program transforms losslessly into legal :core. The mechanical check for that promise is:
janus validate --promotable hello.jansIt runs your file through parse → top-level desugar → auto-import injection → sema, stops there, and reports the verdict:
- Exit 0 –
<file> is promotable to :core. The Script Law holds for this file. Promotion is guaranteed to round-trip. - Exit 1 –
Script Law violated for <file>: <error>. The diagnostic names which rule fired.
Run this in CI before you ship any .jans file you intend to promote later. It is faster than a full compile (no lowering, no codegen, no linking) and it catches every Script-Law violation that sema can see.
Common Script-Law diagnostics
Section titled “Common Script-Law diagnostics”| Code | Meaning |
|---|---|
E3100_EMPTY_SCRIPT | The .jans file has no decls and no statements. There is nothing for pass 1 to lift. |
E3102_USER_MAIN_WITH_TOP_LEVEL_STMTS | You wrote your own func main AND left top-level statements outside it. Pick one. (See the three synthetic-main cases.) |
E3104_RETURN_AT_MODULE_SCOPE | A return appeared at module scope. Pass 1 cannot lift it without changing semantics. (Currently dormant – the parser does not yet produce top-level return_stmt.) |
E3106_RESERVED_SCRIPT_MAIN_ATTRIBUTE | User code applied #[script_main]. The attribute is reserved for the desugar synthesis path. |
E3108_AUTO_IMPORT_RESOLUTION_FAILED | One of the SPEC-045 §3.4 auto-imports could not be resolved. Names the auto-import surface so the diagnostic is not generic. |
E3112_SERVICE_CONSTRUCT_AT_SCRIPT_TOP_LEVEL | A :service-only construct (spawn, await, select, nursery, receive) appeared at :script top level. Promote to :service or escalate the single call. |
E3113_AUTO_IMPORT_SHADOWED (warning) | A user use declaration shadows an auto-import. Compile succeeds; the warning prevents surprises during promotion. |
E3114_SYNTHESIZED_MAIN_ENDS_IN_PANIC (warning) | The synthesized main’s last statement is a panic(...) call. Fires only on a literal panic callee – divergent while true loops are correctly suppressed. The warning exists because a script that ends in a panic almost always means the author meant to return a value or status code. |
The full table lives in SPEC-045 §10.
When to promote, when not to
Section titled “When to promote, when not to”The whole point of the Script Law is that promotion is a non-decision when you decide to ship. Run the validate check, fix anything it flags, and your .jans is mechanically equivalent to a .jan you could have written by hand.
You should promote when any of these become true:
- You are publishing the artifact through Hinge.
:scriptartifacts are non-publishable by design (E3101_PROFILE_NOT_PUBLISHABLE); promote to:corefirst. - The script has outgrown auto-imports.
:corelets you import only what you actually use. - You want
func main’s signature to be explicit (e.g. precise error type, custom allocator). - Code review requires the import surface to be visible at the file head.
You should not promote when:
- The script is a one-shot experiment.
- You are still iterating on the algorithm and rewrites are cheap.
- The whole point is “I want to read the answer once and throw the file away”.
:script is the bazaar. :core is the monastery. Both speak the same language; the bazaar just makes the boring parts invisible.
What this Tier 2 sprint ships, and what it still does not
Section titled “What this Tier 2 sprint ships, and what it still does not”Honesty about the runway. The Tier 2 sprint shipped:
- Pass 1 top-level lifting +
#[script_main]synthesis (SPEC-045 §3.1). - Pass 2 implicit
tryinjection, gated on#[script_main], walker stops at closure boundaries (SPEC-045 §3.2). - The fixed auto-import set, OS-layered paths, with the
auto_import_known_brokenfilter for SI-1/SI-2/SI-3 (SPEC-045 §3.4). janus desugarCLI with-o/-fflags and a verbatim-copy path for.janinput (SPEC-045 §6.b, §7.6).janus validate --promotableparity with the desugar pipeline (SPEC-045 §7.6).- Round-trip harness over 10 fixtures including the implicit-
trycase. - ASTDB parity – the lowered AST is byte-identical to a hand-written
.janfile producing the same output. - Diagnostics E3100, E3102, E3104, E3106, E3108, E3112, E3113, E3114.
It does not yet ship:
- Comment preservation through
janus desugar. The lowered AST has no comment nodes; the printer cannot emit what is not there. Scheduled with the Phase 1 sugar sprint backlog. std.text.rx,std.text.stream,std.text.searchauto-imports. Listed in SPEC-045 §3.4 but currently filtered from emission via theauto_import_known_brokenconstant incompiler/desugar/script.zig. They will auto-emit when SI-1/SI-2/SI-3 close, with no spec or doc change required.E3104_RETURN_AT_MODULE_SCOPEis wired but dormant – the parser does not yet produce top-levelreturn_stmt. The diagnostic will fire when it does.- A REPL for
:script(Tier 4 ambition).janus run+ shebang scripts cover the Tier 1 / 3 dev loops. A live REPL is a separate sprint.
Next steps
Section titled “Next steps”- Read the spec: SPEC-045
:scriptprofile. Section 0 (Purpose + the Script Law), §3.1 (top-level desugar + the three synthetic-main cases), §3.2 (implicit-tryinjection), §3.3 (closure-walker rule), §3.4 (auto-imports), §6.b (canonical printer rules), §7.6 (janus desugar+janus validate), §10 (error codes). - Read the escalate spec: SPEC-044 §3.2.1 single-expression form.
- Try it:
janus run examples/your_script.jans, thenjanus desugar examples/your_script.jansto read the lowered:core, thenjanus validate --promotable examples/your_script.jansfor the falsifiability check. Those three commands are the Tier 2 dev loop.
When you are ready to widen the scope – multiple files, explicit imports, custom allocator strategies – the :core profile is one promotion away.