Static Checking​

Every engine checks a script before it runs it. The checker is one pass over the parsed program, it is always strict, and there is nothing to turn on. This page is the host's view: the API, what the checker can see of your registrations, and where it deliberately stays silent. Strict Checking in the guide teaches the same rules from the script author's side. The model:

  • A mismatch between two declared types is a load error. So is an undeclared name. execute(), jaibite() and jaibite_load*() throw jai::static_check_error before the first statement runs, and what() lists every error in the program with its line and a caret.
  • Only a value that arrived through var can still fail at run time, where try/catch sees it. Load and run time use one conversion table, so a value the checker admits is a value the runtime converts.
  • Where the checker cannot be certain it says nothing. A program that runs clean and violates no declared type gets zero errors (the false-positive contract).
  • Errors gate the load; warnings never do. [[warn_mode]] turns a region's errors into warnings, and extern declares a name the host provides at run time (escape hatches).
  • The runtime never changes. Checked or not, both backends enforce every typed slot themselves.
jaiscript · tower.jai
class Tower { int range = 3; }
Tower t = Tower();
t.rnage = 5;                      // a typo, caught before anything runs
int shots = "many";               // typed to typed: also a load error
output
static check failed:
<script>:3:3: error: no member 'rnage' in class 'Tower' (declared at 1:7)
    t.rnage = 5;                      // a typo, caught before anything runs
      ^
<script>:4:5: error: cannot initialize 'shots' declared 'int' with 'string'
    int shots = "many";               // typed to typed: also a load error
        ^

The check runs once per unique source, when the parse is cached. That one hook covers execute(string), bite creation and load, hot-reload re-parses and registration changes.

Check API​

eng->check(source) parses (cached) and checks without running, and returns a jai::check_report. eng->last_check_diagnostics() is the report of the last checked execute or bite.

The source below has a bad assignment on line 2 and an undeclared name on line 3. The host checks it, runs a variant whose unit is a warn region, then runs the source itself:

C++
// compiled and run against the library
std::string src = "int x = 5;\nx = \"hello\";\nint y = missing + 1;\n";
jai::check_report r = eng->check(src);
std::cout << "diagnostics: " << r.diagnostics.size() << "\n";
for (const jai::check_diagnostic& d : r.diagnostics)
    std::cout << "  " << d.line << ":" << d.column << " " << d.message << "\n";

auto v = eng->execute("[[warn_mode]]; int x = 5;\nif (false) { x = \"hello\"; }\n42").as_int();
std::cout << "warn region ran: " << v << " with " << eng->last_check_diagnostics().diagnostics.size()
          << " warning(s) on record\n";

try { eng->execute(src); }
catch (const jai::static_check_error& e) { std::cout << "load refused:\n" << e.what() << "\n"; }
std::cout << "clean load: " << eng->execute<int>("2 + 2") << "\n";
output
diagnostics: 2
  2:4 cannot assign 'string' to 'x' declared 'int'
  3:9 use of undeclared identifier 'missing'
warn region ran: 42 with 1 warning(s) on record
load refused:
static check failed:
<script>:2:4: error: cannot assign 'string' to 'x' declared 'int' (declared at 1:5)
    x = "hello";
       ^
<script>:3:9: error: use of undeclared identifier 'missing'
    int y = missing + 1;
            ^

clean load: 4
  • The checker is flow-insensitive: the warn region reports the assignment inside if (false), as a warning, though the runtime never reaches it.
  • A check_diagnostic carries severity, line, column, message and related_line/related_column (the declaration a bad store violates).
  • A check_report holds the diagnostics vector, capped (the 100-diagnostic cap was hit), has_errors(), error_count() and format(source, name), which renders the listing above.
  • check() throws jai::parse_error only when the source does not parse, so a build step can walk every script and fail on has_errors() without executing anything.
  • One bad line produces one diagnostic: an erroring expression assumes its declared or expected type afterwards, and identical (line, column, message) triples are reported once.

One conversion table​

The checker at load and every typed slot at run time (declarations, assignment, by-value parameters, returns, instance and static field stores, container elements) run the same table. The reference has it row by row (type conversions) and the guide walks through it (one conversion table). What a host most needs from it:

  • A string slot takes text or a char, never a number, and an int slot never takes a string. A bound object converts into a primitive slot through its to_int(), to_float(), to_string() or to_char().
  • A class slot takes null, the class or a subclass, a handle of one, or a source that exactly one one-parameter constructor takes directly; a plain assignment also takes what an operator= of the class or an in-script base accepts. A registered ancestor's operator= is not checked.
  • var takes anything, and everything computed from a var is unknown to the checker. auto with an initializer is inferred, then enforced; without one, or initialized with null, it is dynamic until the runtime locks it at the first non-null store.
  • The table never chains (TA -> TB -> TC, or x.to_int() then C(int), is not implicit) and never hides a throw: a converting constructor or to_X() that throws surfaces its own error.
  • A typed x op= rhs is x = T(x op rhs), as in C++: int x; x += 2.5 and x += "s" are errors, and a var target keeps its dynamic result.
  • Static fields are typed like instance fields on every spelling: C::s, a bare s inside the class, and the compound forms.
  • A run-time rejection names its slot and the value's own type, never a var tag: Cannot assign string to 'x' (declared int), Cannot pass string to parameter 'n' of 'fib' (declared int), Cannot return string from 'f' (declared -> int), Cannot convert S to C (no suitable single-argument constructor). A return's conversion is part of the return statement, so a try around it inside the callee catches the failure.

What gets checked​

Calls​

A call is checked when its callee resolves statically. Script functions, constructors and methods get arity (defaults counted) and per-argument assignability through the table; an overload set errors only when no overload matches. Callables held in variables (lambdas, function values from an earlier execute) are unchecked.

Host functions are checked through the signatures their registration records (engine::host_function_signatures):

  • Arity across overloads. A variadic function accepts anything.
  • Per argument, only certainties: a known primitive with no builtin conversion to a primitive parameter, like square("s"). Numeric to numeric passes, and object and script_value parameters accept anything.
  • A C++ std::string parameter takes text, as a script string slot does: a number is not converted.
  • A bound static method (Gate::twice()) is held to its binding's arity. A second static_method of the same name replaces the first, and a binding of unknown arity accepts anything.

Returns​

A returned value must be assignable to the declared return type, and a value returned from a void function is an error. A bare return; or a path that falls off the end is an error when the result cannot be null (a primitive, a string, an enum, a class value, a reference) and a warning for a handle or container result (every path returns). Coroutines are exempt: a call types as coroutine_handle, and of its yield and return types only a float into an int is checked.

Identifiers​

An undeclared identifier is an error. A name is declared when it is:

  • in scope, or a member of the current class (inherited ones included; inside a method or a [this] lambda, also a member of a derived class);
  • a program-scope declaration of the unit, top level or in a namespace body (two-pass, so a function reads a global declared below it);
  • a top-level name of any unit in the program (Programs);
  • an extern declaration (extern);
  • one of the host's per-call instance variables;
  • anything on the engine's registration surface when the script loads: globals, functions, classes, template types, namespaces.

So register before you execute, or declare the late names extern. A local, parameter or loop variable is declared only in its scope and a field or method only to its class: print(hp) at the top level is undeclared even when a class declares hp.

A unit that uses a dynamic-path import(expr) or include(expr) reports undeclared identifiers as warnings, because such a file can define anything. A literal path never hides a name, wherever the import stands: its module joins the program.

References​

A reference binds compatible storage and never converts: the exact primitive kind (an int& takes an int, not a float), the class or a subclass, or the same concrete container or handle type. The checker holds parameters, reference declarations, reference range-for variables and reference returns to that rule; an argument whose element type it cannot know passes, and the runtime gives the same verdict. A store through a reference is left to the runtime, which runs the referenced variable's own table (through references).

Members​

On a receiver whose class is fully known, an unknown field or method is an error on reads and writes (the run-time twin is Cannot assign to 'x': A has no such member). A class is fully known when it is an in-script hierarchy the checker can resolve, or an engine-registered class with its properties and statics. A member only a subtype declares is an error on a base-typed receiver, as in C++: cast<Archer&>(u).arrows or a var receiver reaches it (guide).

Accessors, bare names, enum values, what stays unchecked and open classes: member checks.

Casts​

A cast types as its target: cast<T>(e) as a certain T, cast<shared_ptr<T>>(e) as the handle, a primitive target as the primitive, and cast<T&>(e) as T for member lookup only. The operand is never checked against T: the verdict is the runtime's catchable Cannot cast 'X' to 'T'. A T that names no class is the error unknown type in cast: 'Nope'. A value-form class cast of a handle or reference warns that it copies the instance (casts); the runner prints warnings under --warnings.

Namespaces and type names​

A qualified name must name something its namespace declares: the longest prefix of a::b::c that names a namespace must declare the next segment, or the load fails with no member 'c' in namespace 'a::b'. Every declaration of the namespace counts: any unit of the program, an earlier run, or the host's registrations (a script's namespace math { ... } extends math::). A namespace that is also a class answers with the class's statics. Every type a declaration spells must name a class, enum or registered type (unknown type 'Nope').

math::<fn>(...) is typed by its result: int for itrunc, ifloor and wrap_*, float for the transcendental and interpolation families, the operand's type for math::abs, min, max and clamp. Its arity is a run-time error.

Programs: checking across imports​

Names cross files, whatever the import order. Here rules.jai reads a global that state.jai declares:

jaiscript · main.jai
import "rules.jai";               // rules.jai reads G, which state.jai declares
import "state.jai";               // order does not matter: names cross units
print(damage(3));
jaiscript · rules.jai
int damage(int hits) { return hits * G.power; }
jaiscript · state.jai
class World { int power = 7; }
World G = World();
output
21

A unit whose import/include statements carry literal paths, at any depth, is the root of a program. Before anything runs:

  1. The engine gathers the closure: every unit those paths reach, resolved as the runtime resolves them (the importing file's folder, then the include paths, then the working directory), recursively, each file once. Cycles are fine.
  2. It hands the checker the union of every unit's top-level names: functions, variables, classes, enums and their values, namespaces and their members.
  3. The root and every closure unit are checked against that union.

An error anywhere fails the root's execute() before any unit runs. what() lists the root's diagnostics, then each erroring unit's listing, formatted against that unit's source and named by its resolved path. Without state.jai the program above fails with:

output
static check failed:
.../rules.jai:1:38: error: use of undeclared identifier 'G'
    int damage(int hits) { return hits * G.power; }
                                         ^
  • Classes cross units. Every top-level class of every unit is modeled as if the unit being checked declared it, so a subclass whose parent lives in another file resolves inherited members bare, through this and on typed receivers, down a chain that may end in a registered C++ class.
  • A parent found nowhere (a dynamic-path import, an extern name used as a base) opens its subclass: bare names in its methods type as unknown and are never undeclared.
  • In a check_report (check(), last_check_diagnostics()), closure diagnostics follow the root's with line and column 0 and the path-qualified location leading the message.
  • The union is remembered for the engine's lifetime, so a leaf re-parsed alone after a hot edit still knows its program's names. It only grows: a stale name is a typo the checker misses, never a false error.
  • A unit the closure cannot resolve, read or parse contributes no names; the runtime reports it when the unit executes.
  • The disk cache agrees. A leaf's checked-clean stamp holds only while the outside names it resolved mean what they meant, so the program without state.jai checks rules.jai again (jaibite).

Escape hatches​

extern​

extern declares a name that exists outside the program: a host global bound after load, a function the running instance registers, a value injected at run time.

FormWhat the checker knows
extern shared_ptr<Game> G;typed: G.hp is checked against Game, and G.mana is the load error no member 'mana' in class 'Game'
extern config;untyped: a var, so reads and stores are dynamic
extern function spawn_on_network;a callable of unknown signature: calls are unchecked. A namespaced one is extern function math::scale;
  • At load the name counts as declared, in every unit of the program.
  • At run time an extern declares nothing. The name resolves when it is used, to a declaration in any unit, the host's add_global or a host function. A use while nothing defines it raises the catchable 'G' is declared extern but never defined (an 'inline' declaration creates it) (a function's text ends at defined).
  • inline T name; creates the variable when no unit has: one variable shared by every unit that declares it, made by the first to run with its initializer or its type's default. A shared leaf that calls into a module only some programs import declares the names it reaches for this way; where the module is imported too, the declaration is inert.

The host defines an extern with an ordinary registration, before or after the script loads:

C++
// compiled and run against the library
eng->execute("extern int G; print(G + 1);");
// throws jai::script_exception: "'G' is declared extern but never defined (an 'inline' declaration creates it)"
eng->add_global("G", eng->make_value(int64_t{41}));
eng->execute("extern int G; print(G + 1);");                  // 42
eng->add_function("spawn_wave", [](jai::script_int n) { return n * 2; });
eng->execute("extern function spawn_wave; print(spawn_wave(3));");   // 6

[[warn_mode]] and [[strict_mode]]​

These set the checker's mode for a region of one unit. They nest and the innermost wins; outside every region the unit is strict. The position decides the region (guide samples):

PositionRegion
Before a statement, anywhere a statement standsThat statement's own subtree. A nested function or lambda body keeps the enclosing mode: [[warn_mode]] self.spawn([](){ x = 5; }); accepts self, the member and the call, and still checks x = 5.
First statement of a block (a function, method or lambda body, an if/while/for body, a bare { })The whole block, nested bodies included.
First declaration of a file (after any comment; the trailing ; is optional)The whole unit. It says nothing about the units it imports, and a [[warn_mode]] leaf never fails its program.
  • In a warn region every error is reported as a warning, still collected in last_check_diagnostics() and check().
  • An undeclared identifier there is unknown, and an undeclared callee is assumed callable with an unknown result, so nothing downstream is proven from it.
  • [[strict_mode]] inside a warn region re-arms strict: its errors gate the load.
  • Both backends treat an attribute as nothing, so a warn region's type mistakes raise the runtime's ordinary errors, such as Cannot assign string to 'x' (declared int).
  • warn_mode and strict_mode are the only attribute names here; [[bogus]] is a parse error. [[ in expression position still opens a nested array literal.

What still fails at run time (the precision map)​

Where the static story is uncertain the checker is silent by design. This is the whole list: a mistake in any of these spots is a run-time error, never a load error.

The list: the precision map. It covers var and what is computed from it, stores through a reference, container elements the checker cannot type, host surfaces with no static signature, dynamic-path imports, ambiguous accessors, coroutine results and shapes decided at run time.

The false-positive contract​

A program that runs clean and does not violate a declared type produces zero error diagnostics. The precision map is how: wherever the checker cannot be certain, it says nothing. So strict checking only rejects scripts that were already broken, and a prototype that lives on var is never flagged.

Proofs: the same walk, put to work​

The walk also stamps proofs on the AST: notes that the conversion a store, argument or return would run is the identity, and the static class of a binary's operands. The VM's compiler reads them to choose typed lowerings and what it may inline. Every lane they select keeps a full run-time guard, and a guard miss runs the ordinary code. Proofs are stamped only where the checker is certain, which excludes every row of the precision map, cross-unit classes until their unit has run, and every accessor-bearing member.

No program's value, printed output, error text or type_of differs between engine::type_proofs(true) (the default) and type_proofs(false).

  • Turning proofs off strips cached stamps at their next execute.
  • engine::last_proof_stats() counts the stamps of the most recent pass, for tests.
  • Proofs are never saved in a bite: they are derived again at creation, load and execute.
  • A hot reload that changes a class's shape re-stamps the entries that read it at their next execute (Hot Reload).

jaibite stamping​

A bite is checked once, at creation: an error makes jaibite() throw, like execute(), and the report rides the bite (bite.checked_clean(), bite.check_diagnostics()). save() stamps a checked-clean bit in the header. On load the stamp is trusted only when the registration fingerprint matches the saving engine's; otherwise the bite is checked again against the loading engine's surface, and an error throws at load. Registrations made after a bite is created do not re-check it: create the bite again. The jaibite chapter has the details.

Cost​

One AST walk per unique source per registration epoch, cached beside the parse, with the proof pass riding it. The walk runs again only when:

  • the registration surface moves (add_function, add_global, add_class, register_template_type, a non-identical redefinition, or the first definition of a script class no program unit declares);
  • the proof switch flips;
  • the source text changes (a hot reload is a new cache entry, and checks the units its new text could break);
  • a failing verdict could change because the program gained names.

Executing the same import root again checks nothing until a file, a registration or the program changes. An execute with per-call instance variables checks fresh each call, without touching the cached stamps. A .jaibite cache stamped checked-clean skips the diagnostics walk on load.

Migrating a loosely typed codebase​

A codebase mid-migration opts down at the narrowest scope that lets it load: [[warn_mode]]; at the top of each legacy file (its diagnostics collect in last_check_diagnostics() as warnings, which is the to-do list), [[warn_mode]] before the few statements that read a field the checker cannot see, such as a migration hook reading a removed field (Hot Reload), and extern for the names the host injects. Re-arm finished parts with [[strict_mode]], and drop the attribute when the file is clean.