Skip to content

: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 :script program is a syntactically valid :core program after janus 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 try injection, the janus desugar CLI, 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.


A :script file ends in .jans and looks like this:

hello.jans
let name = "World"
println("Hello, {name}!")

No func main. No use std.io. Just statements. Run it with:

Terminal window
janus run hello.jans

The compiler does four things at parse time, in order, before sema even runs:

  1. Top-level statements lift into a synthesized main (SPEC-045 §3.1).
  2. Implicit try(...) is injected at every fallible call site in the synthesized main (SPEC-045 §3.2).
  3. 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.
  4. The result is canonical :core – the same AST a hand-written .jan file 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).


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.io
use std.os.fs
use std.os.process
use std.text.peg
use std.collections
use std.fmt
use std.os.env
func main() : !void do
let name = "World"
println("Hello, {name}!")
end

Two transformations, both purely additive:

  • The fixed set of use lines 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 :core and write the imports you need.


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:

ModuleStatusWhat it gives you
std.ioemitsprint, println, eprintln, stdin, stdout
std.os.fsemitsread_file, write_file, glob, Path
std.os.processemitsspawn, the runtime backing shell literals
std.text.pegemitsTyped PEG grammars, Peg[T]
std.collectionsemitsVec, Map, Set, Deque
std.fmtemitsThe string interpolation runtime ($"...")
std.os.envemitsEnv.get, Env.args
std.text.rxfiltered (SI-1)Compile-time regex literals (r/.../), Regex type
std.text.streamfiltered (SI-2)TextStream, grep, field, replace, …
std.text.searchfiltered (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 fine
println("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::println replaces the wildcard for that name. Janus’s use std.io.{println} does not – the auto-imported std.io wildcard still resolves other members. This deliberate divergence preserves discoverability for :script users (they can call any std.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 :script they 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)”
hello.jans
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}!")
end

Pass 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}")
end

This 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 file
let contents = read_file("config.kdl")
let parsed = parse(contents)
println(parsed.title)
// after pass 2 – what sema sees
func main() : !void do
let contents = try(read_file("config.kdl"))
let parsed = try(parse(contents))
println(parsed.title)
end

This 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.

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.

  • The file is in case 2 above (user wrote their own func main). User-authored main is :core semantics, full stop. No implicit try.
  • 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 solve
let model = escalate :compute lstsq(X, y)
// game script – :cluster capability inline in a behavior tree
bt.action(|| escalate :cluster spawn alert_others(self.pos))
// agent script – :service capability for one HTTP call
let 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.


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.

Terminal window
janus desugar hello.jans # writes :core source to stdout
janus desugar hello.jans -o hello.jan # writes to file (refuses to overwrite)
janus desugar hello.jans -o hello.jan -f # force-overwrite an existing target

The 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.

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 Tier 2 sprint shipped a round-trip harness over 10 fixtures including the implicit-try case. The verified property is:

Building and running <file>.jans produces the same stdout as building and running janus 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:

Terminal window
janus validate --promotable hello.jans

It 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 1Script 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.

CodeMeaning
E3100_EMPTY_SCRIPTThe .jans file has no decls and no statements. There is nothing for pass 1 to lift.
E3102_USER_MAIN_WITH_TOP_LEVEL_STMTSYou wrote your own func main AND left top-level statements outside it. Pick one. (See the three synthetic-main cases.)
E3104_RETURN_AT_MODULE_SCOPEA 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_ATTRIBUTEUser code applied #[script_main]. The attribute is reserved for the desugar synthesis path.
E3108_AUTO_IMPORT_RESOLUTION_FAILEDOne 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_LEVELA :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.


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. :script artifacts are non-publishable by design (E3101_PROFILE_NOT_PUBLISHABLE); promote to :core first.
  • The script has outgrown auto-imports. :core lets 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 try injection, 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_broken filter for SI-1/SI-2/SI-3 (SPEC-045 §3.4).
  • janus desugar CLI with -o / -f flags and a verbatim-copy path for .jan input (SPEC-045 §6.b, §7.6).
  • janus validate --promotable parity with the desugar pipeline (SPEC-045 §7.6).
  • Round-trip harness over 10 fixtures including the implicit-try case.
  • ASTDB parity – the lowered AST is byte-identical to a hand-written .jan file 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.search auto-imports. Listed in SPEC-045 §3.4 but currently filtered from emission via the auto_import_known_broken constant in compiler/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_SCOPE is wired but dormant – the parser does not yet produce top-level return_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.

  • Read the spec: SPEC-045 :script profile. Section 0 (Purpose + the Script Law), §3.1 (top-level desugar + the three synthetic-main cases), §3.2 (implicit-try injection), §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, then janus desugar examples/your_script.jans to read the lowered :core, then janus validate --promotable examples/your_script.jans for 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.