Skip to content

Semantic Analysis Passes

← Back to Documentation | Architecture

Detailed documentation of Sushi's multi-pass semantic analysis pipeline.

Pass Overview

The passes have NAMES, not numbers. A number goes out of order the moment a pass is inserted between two others, which is what the old numbered scheme did to itself. SemanticAnalyzer.check() (semantics/semantic_analyzer.py) is the code authority on the order; this list mirrors it.

Pass What it does Where
collect constants, function headers, generic types, externals semantics/passes/collect/
docs check each doc block against its declaration (CE7001-CE7008, CW7001), and its completeness under --warn-missing-docs (CW7002-CW7006) semantics/passes/docs.py
externs extern signatures (CE5003), CW5001, the ptr unit gate (CE5009) semantics/passes/types/externals.py
libraries register every symbol a .slib exports semantics/semantic_analyzer.py
namespaces bind what each unit may write behind a dot, and what its flat scope holds (CE3013, CE3014, CE3016, CW3004, CW3005) semantics/passes/namespaces.py
ffi-clash reject an unsafe external that names a symbol this build defines (CE5013) semantics/passes/types/externals.py
entrypoint main()'s signature and its string[] args semantics/semantic_analyzer.py
instantiate collect every generic instantiation the program asks for semantics/generics/instantiate/
monomorphize generic definitions become concrete instances semantics/generics/monomorphize/
resolve struct field and enum variant types become concrete semantics/passes/resolve.py
finite-types reject a type that contains itself by value (CE2095) semantics/passes/finite_types.py
derive auto-derive hash() and clone() semantics/passes/derive.py
shadowing reject an extension method that collides with a built-in (CE2097) semantics/semantic_analyzer.py
effects which functions destroy a poke parameter, transitively semantics/passes/borrow/destroy_effects.py
scope scope and variable analysis semantics/passes/scope.py
typecheck type validation and inference semantics/passes/types/
lift each lambda becomes a top-level function plus an environment semantics/passes/lift.py
borrow borrow checking semantics/passes/borrow/

The last four run per unit, in one loop, so the whole-program passes above them see every unit before any function body is walked.

semantics/passes/const_eval.py is not a pass. The typecheck pass and the backend both call it as a helper.

The word "phase"

"Phase" names the three sub-steps of the typecheck pass per statement — resolution → propagation → validation. It never names a pass. Where you meet #300 phase 2 or "Phase 9" in the tree, those are work phases of an issue or of a past project, not passes.

The collect pass: headers and constants

Files: semantics/passes/collect/*.py

Purpose

Collect global definitions before analyzing function bodies.

Responsibilities

  1. Constants: Parse and register constant definitions, and unit variables (var) in the same table with is_var set (docs/design/unit-storage.md)
  2. Function Signatures: Collect return types and parameters
  3. Generic Types: Register struct and enum definitions
  4. Symbol Table: Build initial global scope
  5. Visibility: Record who declared what, and whether it says public
  6. Extension methods, instance and STATIC alike, with is_static carried on the collected signature (docs/design/method-resolution.md). The two refusals a static brings are structural and so are decided here: a receiver named in the signature or the body is CE0134, and a static spelling a variant of the enum it extends is CE2103. A static inside a perk implementation is CE4014, in the perk collector.

A unit is collected after the units it depends on

The compilation order (UnitManager.topological_sort) yields every unit AFTER the units it depends on. The walk itself counts in-degree as "how many units depend on me" and so produces the opposite; the result is reversed once, and the direction is a ruling (docs/design/unit-namespaces.md section 13.2): a unit's scope is built from what its own imports declare, so the declaring unit has to be collected already.

A source library's units and a bundled Sushi-source stdlib module are injected as ordinary compilation units, and build_dependency_graph records the edge that an import of one creates. That is why a library unit comes first without being told to. A binary .slib matches no unit and adds no edge, because it has no unit to compile.

Two hand-patches retired with the order. Library units were pulled to the front of the collect loop, and every unit's perk DEFINITIONS were swept up ahead of the loop so that an implementation could meet the two rules that read the perk table -- the perk exists (CE4003), and its marker lets this unit implement it (CE4011). A perk declared next door is in the table when the implementing unit is reached, so neither patch is needed.

Example

const i32 MAX = 100  # Register constant

struct Pair@(T, U):   # Register generic struct
    T first
    U second

fn add(i32 a, i32 b) i32:  # Register signature
    return Result.Ok(a + b)

Output: - constants = {'MAX': 100} - functions = {'add': FunctionSignature(...)} - generic_types = {'Pair': GenericStruct(...)}

One seam for who may name what

semantics/visibility.py is the one answer to "may unit U name declaration D". One record (DeclOrigin), one predicate, and four sets that classify every kind the declaration walk yields: a kind carries its own marker, follows the declaration it is part of, follows the type it is attached to, or has no visibility at all. tests/unit/test_visibility_seam_is_total.py asserts the union is exactly the walk, in both directions, so a new declaration kind cannot get half the rule.

The table is filled at the END of each unit's collection, from declarations() -- the same total walk the docs pass uses -- so the gate on that walk protects the seam. The merger replays it once per unit, which is why record() is idempotent.

Two facts it has to carry beyond the marker. A name with no record is public: the compiler synthesizes types nothing declared (a monomorphized instance, a lifted closure environment, FileMode), and none of them can carry a source marker. And the table remembers the LOSER of every contested name, because a unit that declared a name must never be shown its own code measured against somebody else's declaration -- which is what "cannot call private function 'helper'" said to the unit that wrote helper itself.

The rules that read it live where the use is: passes/types/visibility.py for a call and a bare constant read, the type funnel for a named type, the collect pass itself for a TYPE declaration that collides with a library's (CE3011) or a declaration that promises something about a private perk (CE4011), and passes/types/public_signatures.py for the leak fence (CE3009, CE3010). docs/design/visibility.md is normative.

One reporter, many files

This pass is the only whole-program pass that walks every unit's AST while sharing ONE reporter -- the per-unit passes each build their own through _unit_reporter(unit). A span is meaningless without the file it came from, so CollectorPass.run names the unit it is reading (Reporter.origin), and Reporter._record stamps it onto every diagnostic the pass raises. Without it, a declaration in a non-entry unit was reported against the ENTRY file: the head line named a line the user did not write, and the caret landed on whatever text sat at that column (#473).

A first defined here note needs one thing more. It points at a table entry, and the entry may have been made while a DIFFERENT unit was being collected, so each record remembers its own file: files beside spans on the struct and enum tables, PerkTable.files, and a filename field on FuncSig, ConstSig and ExternalSig. note_first_declaration is the one place that reads them.

Limitations

Constants can only be literal values (no expressions).

FFI External Collection

semantics/passes/collect/externals.py builds an ExternalTable from each unsafe external "C" block: a namespace-keyed map of ExternalSig (Sushi name, link name, param/return types). It rejects duplicate names within a namespace and emits CE5001 when a link-name clashes with a RESERVED_EXTERNS built-in of a different signature. The table is exposed as collector.externals and threaded into the scope pass, the type validator, and the backend.

The C-ABI allowlist check (CE5003) and the CW5001 four-guarantee warning live in semantics/passes/types/externals.py::validate_external_signatures, run right after collection.

The docs pass: a doc block against its declaration

File: semantics/passes/docs.py

A doc block is part of the declaration (docs/design/documentation.md), so the compiler can check what the block claims against what the declaration says. A - Parameter q: that names no parameter of this function is wrong, and the compiler knows it is wrong.

##:
Adds two numbers.

- Parameter q: CE7001 -- there is no parameter called q.
:##
fn add(i32 a, i32 b) i32:
    return Result.Ok(a + b)

Eight errors and one warning, all of them always on. check_docs is the entry point:

Condition Code
a - Parameter tag names no parameter of this callable CE7001
two - Parameter tags for one name CE7002
a second - Returns: or - Errors: CE7003
an unrecognised tag keyword CE7004
a block in a body that is not the first item CE7005
a declaration with a block above it and a block first in its body CE7006
an - Example: tag that introduces no fenced block CE7007
a fence inside a block that is never closed CE7008
a block that documents nothing CW7001

Every check finds a claim that CONTRADICTS the declaration, which is why none of them is behind a flag.

Behind --warn-missing-docs

Completeness is the other half, and it is policy rather than contradiction, so the CALLER decides. check_missing_docs is a second entry point, and _check_multi_file runs it in the same loop only when the flag is set. The pass holds no policy flag of its own: that is what keeps the always-on side unable to drift behind a flag.

Condition Code
a declaration with no doc block CW7002
a documented callable with a parameter that no - Parameter tag names CW7003
a documented callable that returns a value, with no - Returns: CW7004
a documented function that declares \| E, with no - Errors: CW7005
a unit with no doc block CW7006

Three rules shape the table. Every declaration is asked, public and private, because an internal API is documented surface too. fn main() and the unsafe external seam are the two exemptions, named in one predicate. And CW7003, CW7004 and CW7005 presuppose a block: a declaration with none collects CW7002 and stops, so one omission is one diagnostic.

One walk

declarations() yields every declaration of a unit with the word for its kind, block or none. documented() filters it, and check_missing_docs asks each yield whether it carries a block. tests/docs_sweep.py reads the same walk, and its order is fixed: the sweep numbers its generated doc_example_<n> helpers from it. tests/unit/test_declaration_walk_is_total.py is the gate.

Placement

Placement is load-bearing on one side. The pass needs the merged unit table and nothing later, and it must run before instantiate and monomorphize: a generic's block is written once, and checking it afterwards would report one mistake once per instantiation. The completeness lint has a second reason to stay there. register_synthesized_function appends a monomorphized clone to a unit's own ast.functions, so a lint that ran later would demand a doc block on every instance the program asked for.

Library units are skipped, both ways. A consumer must not be told about the library author's doc typos, and must not be warned once per undocumented symbol in every library it imports.

The externs pass: FFI signature validation

File: semantics/passes/types/externals.py

Runs over every unit right after collect, so no later pass ever meets an extern the C ABI cannot carry.

  • validate_external_signatures() — the C-ABI allowlist (CE5003) and the CW5001 four-guarantee warning. A variadic libc extern must be declared var_arg; a fixed declaration reads garbage on Apple arm64.
  • validate_ptr_unit_gate()ptr is opaque and quarantined to a unit that declares the extern block it came from (CE5009).

See docs/ffi.md.

The libraries pass: library symbol registration

File: semantics/semantic_analyzer.py (_register_library_*)

Every symbol a linked .slib exports enters the same tables the consumer's own collect filled: structs, enums, functions, published constants, export-closure private helpers and constants, perk implementations, and generic templates.

A constant is registered from SOURCE, whichever list it came from: it has no body to link, so the manifest carries the declaration's text and the consumer re-parses it. A clash with the consumer's own name is CE0105 for a published constant -- the answer a source library gives for the same program -- and CE5007 for a closure one, which may not be renamed because the library's own bodies call it.

Placement is load-bearing at both ends. Perk DEFINITIONS are seeded BEFORE the collect loop, because perk-impl collection validates each impl against the visible definitions (CE4003). Perk IMPLEMENTATIONS register here, after the consumer's own (local wins) and before instantiate, so the constraint validator sees them. Generic structs register before generic enums, because an enum payload may name a struct.

A clash between a library's export-closure helper and a local name is CE5007: local-wins would silently change what the library's monomorphized bodies call. See docs/design/libraries.md.

The namespaces pass: what a unit may write behind a dot

File: semantics/passes/namespaces.py; the seam is semantics/namespaces.py.

Builds one NamespaceTable per unit, because an alias is local to the unit that wrote it. A namespace is a binding from a name to a set of declarations, and four providers make one:

Provider Bound by
ExternalNamespace an unsafe external "C" as <ns> block
UnitNamespace use "path" as N, a library unit, a bundled source module
StdlibNamespace use <math> as N and every other registry module
GenericNamespace use <collections/hashmap> as N -- the built-in the import activates

A binding holds the PROVIDER and never the written path: _inject_library_source renames a library's units and leaves UseStatement.path alone, so an alias built from the path would break the moment a library unit imported its sibling.

A stdlib provider also lists the PREDEFINED enums homed at its module (#574, Ruling 3). No unit declares FileMode, so no declaration record can say who may write it; the collect pass stamps each of the nine with its home (EnumType.home_module, the table is passes/collect/enums.py:PREDEFINED_ENUM_HOMES), homed_enums reads the stamp for the provider, and the typecheck pass's type-position gate (reject_out_of_scope_type) reads it to refuse the bare name where the home is not imported -- the HashMap rule. StdError carries no home and stays global.

A provider COMPOSES what its unit re-exports (unit-namespaces.md section 8.1, Ruling 7). _unit_provider reads the unit's own public use statements and builds a provider for each through _provider_for, recursively and with a visited set, so a chain composes and a cycle terminates; a registry module names its re-exports in StdlibModule.reexports instead. Provider.reaches walks the chain once -- the provider, then each re-export in written order, then theirs -- and both halves of the answer read it: lookup/members for the dot, and _scope_of for the flat scope, which puts every reached unit, module and generic into the importer's UnitScope. A binding a re-export answers carries the re-export's own provider, so the back end routes a call through sh.origin to the unit that declares origin.

Five rules:

  • CE3014 -- a use below a declaration. The span comes from the AST builder, because the libraries step above appends a library's constants and private types to a host unit's lists and each carries a span from its own file.
  • CE3013 -- the alias is already bound in this unit: another alias, an FFI namespace, or one of its own declarations. _ is refused too, as the discard name.
  • CW3004 -- the as reached no name. A warning, because a namespace is empty for three reasons and only one is a mistake (unit-namespaces.md section 4.4).
  • CE3016 -- public use ... as. A re-export is of names and not of a namespace; the alias still binds, so the one fault gets one diagnostic.
  • CW3005 -- a public use whose import brings no PUBLIC name. The provider holds the privates too (so u.hidden is CE3005 and not "no such name"), and the count here is of what the re-export can hand on.

Why it stands between libraries and ffi-clash

A provider needs what collect and libraries produce and nothing later. collect fills a unit's own declarations, the FFI table and the registry modules; a BINARY library has no AST at all, so its declarations exist only once libraries has read the manifest. ffi-clash is the first step that asks whether a name is already taken, and the first that has to ask it of ONE unit.

Two seams, in order

This pass answers WHERE a name may be written. semantics/visibility.py answers WHETHER it may be named. So a namespace holds a unit's declarations whatever their visibility, and a private one is refused at the use site with CE3005 -- filtering privates out would turn "not yours" into "no such name".

The typecheck pass reads the table through TypeValidator.resolve_namespaced, and the scope pass through _is_namespace. Both used to carry their own copy of the local-wins rule.

The entrypoint pass: main()'s signature

File: semantics/semantic_analyzer.py (_check_main_function_args_multi_file)

main takes no parameters or exactly one string[] args. The args array is a BORROWED view of argv, so moving it is CE2410.

The instantiate pass: generic instantiation collection

Files: semantics/generics/instantiate/*.py

Purpose

Detect which generic instantiations are needed.

How It Works

  1. Traverse AST looking for generic types
  2. When List@(i32) appears, record it
  3. When .push() is called on List@(i32), record List@(i32).push
  4. Build complete set of required instantiations

A generic call's substituted signature

A call to fn wrap@(T)(nom T v) Box@(T) with a string names Box@(string), and the program may name that instantiation nowhere else: a match arm binds the payload, or the value is passed straight on. The generic-target extension and perk-implementation copies are cut from the set this pass collects, so the pass records the SUBSTITUTED signature of every generic call it resolves -- the return, the Result the declaration wraps it in, and the parameters -- through the same type walk a concrete declaration gets (#549, #555).

The typecheck pass's inferrer types a generic call through its monomorphized copy, which does not exist yet, so it answers nothing for one here. A match over a generic call therefore types its arm bindings from that substituted signature, and a generic called with such a binding is collected like any other (#549).

Where a type names an instantiation

A type names an instantiation in every position that HOLDS a type, and the reader of those positions is type_walk.walk_named_types -- the one walk over a type. peek Box@(string), fn(i32) -> Box@(string) and a struct field of that function type each name Box@(string), and the recursion written here saw an array, a struct and an enum alone: the declaration answered CE2001 for a type the program declares (#603).

There are two node handlers over that one walk, because the two readers see two spellings of one instantiation. instantiate/type_collection.py reads a WRITTEN type -- a GenericTypeRef, whose arguments the resolver resolves -- and monomorphize/functions.extract_type_instantiations reads a SUBSTITUTED one, which IS the instance and carries the base it came from. tests/unit/test_instantiation_collection_is_total.py is the gate: a kind the walk enters needs an answer from both.

Example

let List@(i32) nums = List.new()  # Collect: List@(i32), List@(i32).new
nums.push(42)                     # Collect: List@(i32).push

let List@(string) names = List.new()  # Collect: List@(string), List@(string).new
names.push("Alice")                   # Collect: List@(string).push

Collected instantiations: - List@(i32) - List@(i32).new() - List@(i32).push() - List@(string) - List@(string).new() - List@(string).push()

The monomorphize pass: generic to concrete

Files: semantics/generics/monomorphize/*.py

Purpose

Generate concrete types from generic definitions.

Process

  1. For each collected instantiation (e.g., List@(i32))
  2. Substitute type parameters (Ti32)
  3. Create specialized struct/function
  4. Add to AST as concrete definition

A late instantiation

A generic BODY names types the collector never saw: let Box@(T) b inside outer@(T) is a Box@(string) only once outer@(string) is substituted. A copy binds its let locals while it walks its body for nested generic calls, so a generic called with one is collected like one called with a parameter, and it interns every type its let annotations name, exactly as it interns its signature's.

A substituted type that is itself an instance -- the Box<string> a Box@(B) field becomes under B := string, a Maybe<string> payload, a Pair<i32, string> return -- is published to its table when it is BUILT (TypeMonomorphizer._publish, #577). The collector sees what the program spells; the substitutor is the one place every producer passes, so publishing there is the worklist, and the analyzer reads the reached instances back as instantiations for the copies below. An abstract instance, a method-level U still unbound while a generic-target template is cut per receiver, is not published.

One source, one report

Every instance carries the TEMPLATE's spans, and each copy is walked by the per-unit passes as an ordinary function -- correctly, because a per-instance truth is only visible there: a consume that is a plain copy for one type argument is CE2411 for an owning one. What must not follow is the COUNT. A fault in the shared body used to be told once per instantiation, at one caret, so the number of reports tracked how many times the caller happened to instantiate the function (#648).

The copy is stamped instance_of with the template's name. Reporter.enter_body(func) reads it -- the one seam every per-unit pass calls to say whose body it is about to read, and the same seam that answers whose FILE the spans belong to (#471) -- and sets collapse_repeats, so a diagnostic whose diagnostic_identity has already been recorded is dropped. The identity is the kind, the code, the MESSAGE, the file and the span, so a finding that genuinely differs by type argument keeps its own message and is still told: v + 1 over an f64 and over a u8 answers two CE2510s at one caret, and both survive.

It is not a general de-duplicator. A repeat anywhere else is a bug to be fixed where it is made, and stays visible -- tests/unit/test_diagnostics_not_duplicated.py is the gate on that, and tests/unit/test_generic_instance_reports_once.py on this.

A lambda in a generic body lifts once per instance, so LambdaLifter carries instance_of onto what it lifts. The borrow pass is the one that walks the template as well as the copies, so a borrow fault was N + 1 rather than N.

The substitution walk is total

TypeSubstitutor.substitute_expr and substitute_statement replace a type parameter wherever an instantiated body names one. Both walks are TOTAL over their node union, and the fall-through is a hard CE0135. A copy is not an acceptable answer: a node with no arm keeps the type parameter, and the compiler's own bookkeeping name -- T, U -- reaches the user (#602). The walk handled a cast and a ?? only, so a cast one level deep answered CE2014, explicit call-site type arguments answered CE2061, a lambda annotation answered CE2002 and a foreach item annotation answered CE2001.

The walk substitutes every type the SOURCE writes: a cast target, the type arguments of a call, a lambda's parameters, return and | E channel, a let annotation and a foreach item annotation. An analysis STAMP is not substituted, because the typecheck pass writes it after this pass and writes it on the copy. INERT_EXPRS names the leaves: a node with no sub-expression and no type of its own, which a shallow copy answers completely.

tests/unit/test_substitution_dispatch_is_total.py is the CI gate, in the shape test_borrow_dispatch_is_total.py gives the borrow pass.

A refused instantiation

An instantiation that violates a perk constraint is CE4006 ONCE, at the first site that named it -- the collector records (span, file) per instantiation for this -- with a note at the constraint, which may stand in another file (a stdlib template's). It is built nowhere: not cached, not published, so no template copy is ever cut for it, and the whole-program analysis STOPS after the monomorphize step, the CE2095 precedent (#579, Ruling 4). The per-unit passes would only have read the same fault back as a CE2008 from inside a copy's body.

The generic-target extension and perk-implementation copies are first cut from the collector's set, before the functions are monomorphized. Every instantiation interned after that -- the tables are the authority on what exists -- gets its copies afterwards, and a copy's body can instantiate more functions, so this runs to a fixpoint (#555). A perk constraint on such a type reads the templates as well as the registered copies, so its answer does not depend on the order the copies were cut in.

Example

Generic definition:

struct Pair@(T, U):
    T first
    U second

extend Pair@(T, U) swap@(T, U)() Pair@(U, T):
    return Result.Ok(Pair(first: self.second, second: self.first))

After monomorphization for Pair@(i32, string):

struct Pair__i32__string:
    i32 first
    string second

extend Pair__i32__string swap() Pair__string__i32:
    return Result.Ok(Pair__string__i32(first: self.second, second: self.first))

Name Mangling

  • Pair@(i32, string)Pair__i32__string
  • List@(T)List__i32, List__string
  • Nested: Maybe@(Maybe@(i32))Maybe__Maybe__i32

The resolve pass: field and variant type resolution

File: semantics/passes/resolve.py

Purpose

Every named type a declaration mentions becomes the one interned type object for that name. The pass runs AFTER monomorphize, so every struct and enum a generic produced is already in the tables.

What it resolves

  1. Struct fieldsresolve_struct_field_types() walks every entry of the struct table and replaces each UnknownType("Point") field with the StructType (or EnumType) the tables hold under that name.
  2. Enum variantsresolve_enum_variant_types() does the same for every variant's associated types.
struct Point:
    i32 x
    i32 y

struct Rectangle:
    Point top_left      # collected as UnknownType("Point")
    Point bottom_right  # resolved here to the interned StructType

Why it matters

Type identity is NOMINAL (docs/design/type-identity.md): a StructType compares and hashes on its name alone. Two spellings of one name therefore hash alike and compare unequal, which poisons the enum table (CE0126). This pass is what makes the table entry the single authority, so every later pass reads a resolved type and never rebuilds one.

The finite-types pass: reject a by-value containment cycle

File: semantics/passes/finite_types.py

A type that contains itself by value has no finite size, and is rejected with CE2095. The escape is indirection: Own@(T), or a dynamic array.

struct Node:
    i32 value
    Node next          # CE2095: infinite size

struct Chain:
    i32 value
    Own@(Chain) next   # legal: a pointer has a size

Placement is load-bearing on both sides. It runs AFTER resolve, because it needs the resolved field types, and BEFORE derive, whose topological sort would report the same cycle as an internal error (CE0128). It is also the one pass that STOPS the analysis on failure: every later pass assumes a finitely-sized type.

The derive pass: hash and clone auto-derivation

File: semantics/passes/derive.py

Purpose

Auto-generate .hash() -> u64 and .clone() for all types.

Algorithm

Primitives: - Integers: FxHash - Floats: Normalized to u64, then FxHash - Strings: FNV-1a - Booleans: 0 or 1

Structs:

hash = FNV_OFFSET_BASIS
for field in fields:
    hash ^= field.hash()
    hash *= FNV_PRIME
return hash

Enums:

hash = discriminant.hash()
hash ^= variant_data.hash()
return hash

Arrays:

hash = FNV_OFFSET_BASIS
for element in elements:
    hash ^= element.hash()
    hash *= FNV_PRIME
return hash

Where a derived method lives

The pass writes each method into SymbolTables.derived_methods, which belongs to ONE compilation (#601). It has to: the method closes over the type it was derived for, type identity is nominal, and two programs compiled in one process that each declare a Point name one key -- so a module-level table handed the second program the first one's emitter, closed over the first one's fields, and the first one's answer to "can this be hashed". Any host that compiles twice in a process reaches that, the pytest layer and a future language server included.

The table is stored on EnumTable.derived and read by name everywhere else (SymbolTables.derived_methods, TypeValidator.derived_methods, LLVMCodegen.derived_methods). The enum table is the carrier because the Result and Maybe interning seams derive a hash the moment they intern an enum and hold only that table; every other reader already holds a validator or a codegen.

A lookup that finds nothing falls through to builtin_registry, the process-wide table of what the compiler defines for EVERY program -- hash, to_str, to_bits and clone on the primitives, registered once at import time from backend/types/primitives/. Those emitters close over a BuiltinType and nothing a program can change, so one table serves the process.

Which types get a hash

hashability_of (semantics/generics/hashing.py) is the one reader. A struct field, an enum payload and an array element all ask it, and its dispatch is total over the type kinds: UNHASHABLE_KINDS names every kind a derived hash cannot read, WALKED_KINDS names the kinds that answer through what they hold, and HASHABLE_KINDS names the primitives. tests/unit/test_hashability_dispatch_is_total.py is the gate.

LET_THROUGH_KINDS is the fourth set, and PointerType is all of it. A pointer has no spelling in Sushi and reaches the walk only in a container the compiler synthesizes -- List@(T).data, Own@(T).value -- and letting it through is what gives those two a derived hash. That hash cannot be emitted: Own@(i32).hash() reads CE0052. Refusing it is therefore right, but it takes the derived hash off every container, so it is a ruling of its own and is named here instead of left to fall through in silence.

The tables have to be explicit. Each of the three walks used to carry its own chain of isinstance arms, and a kind no chain named fell out of the loop untouched -- which reads as hashable. A struct with a fn(i32) -> i32 field therefore got a hash() the backend could not emit, and the user read CE0052: an internal error, with no file and no line, about a program that was theirs to fix (#618).

A type that derives no hash() has no such method, so a .hash() call on it is CE2008 at the call site, with the line and the caret.

Limitations

Nested arrays cannot be hashed (type system constraint).

The shadowing pass: an extension may not shadow a built-in

File: semantics/semantic_analyzer.py (_check_extension_shadows_builtin)

All three resolution layers pick a built-in method before an extension method, so an extension whose name collides with one could never be called. That is CE2097 rather than silent dead code (#239).

Placement is load-bearing at BOTH ends: after derive, which registers the struct and enum hash/clone, and after the generic-extension table merge, which is where a monomorphized extend Box@(i32) hash() enters the extension table.

A perk implementation is unaffected by construction — an ExtendWithDef never enters the extension table. It is the sanctioned way to replace a built-in. See docs/design/method-resolution.md.

The effects pass: the destroy-effect summary

File: semantics/passes/borrow/destroy_effects.py

Which functions destroy a poke parameter, transitively (#168). The borrow pass reads the summary to decide whether a call invalidates the caller's value.

Computed ONCE over EVERY unit, because borrow runs per unit: a per-unit summary would make a cross-unit callee invisible.

The scope pass: scope and variable analysis

File: semantics/passes/scope.py

Purpose

Track variable lifetimes, scopes, and ownership.

Responsibilities

  1. Variable Declarations: Register all let declarations
  2. Scope Analysis: Track block-level scopes
  3. Move Semantics: Mark variables as moved
  4. Usage Tracking: Detect undefined variables
  5. What KIND of name is this: the bare-name ladder, from semantics/name_ladder.py

The bare-name ladder

docs/design/unit-namespaces.md section 8 gives an unqualified name one ordered ladder over the KINDS it can reach: a local, a constant, a registry constant, a function, a namespace, a type, nothing. This pass and the typecheck pass both walk it, and the ORDER lives in semantics/name_ladder.py so neither can drift from the other -- which is what happened at the type rung, where an enum name in a value position escaped both passes and died in the emitter as CE0055 (#600). Each pass answers one question per rung with its own lookups (ScopeAnalyzer.is_localis_type, and visitor._InferenceRungs), classify walks them, and tests/unit/test_bare_name_ladder_is_one.py is the gate.

This pass owns the two rungs that are not values: a type name in a value position or under a borrow is CE2105, and a name that reaches nothing is CE1001.

Variable States

  • Declared: Variable exists in scope
  • Moved: Ownership transferred, cannot use
  • Destroyed: Explicitly destroyed via .destroy()
  • Borrowed: Temporarily passed by reference

Examples

Valid:

let i32 x = 42
let i32 y = x  # OK: primitives copy

Invalid:

let i32[] arr = from([1, 2, 3])
let i32[] moved = arr
println(arr.len())  # ERROR CE2405: Use of moved variable 'arr'

Scope Tracking

fn example() i32:
    let i32 x = 1  # Scope 0 (function)

    if (true):
        let i32 y = 2  # Scope 1 (if block)
        x := 3         # OK: x from outer scope

    # println(y)  # ERROR CE1003: Undefined variable 'y'

    return Result.Ok(0)

The typecheck pass: type validation

Files: semantics/passes/types/*.py

Purpose

Ensure all expressions and statements are type-correct.

Modular Type Checking

types/utils.py - Type utilities - is_numeric(), is_integer(), is_float() - Type comparison and normalization

types/inference.py - Type inference - Infer types from literals - Propagate types through expressions

FFI call-site resolution - type_visitor.py::visit_dotcall (both the ExpressionValidator and TypeInferenceVisitor) has a new first branch: when the receiver is a Name that is a registered external namespace and not a bound local (locals shadow namespaces), it resolves the ExternalSig, validates argument count/types, sets the inferred return type to the raw C type (no Result wrapping), and annotates the node with external_ref = (ns, name) for the backend. ?? on a raw foreign value therefore falls out as the existing CE2507.

types/compatibility.py - Type compatibility - Check if type A can be assigned to type B - Handle Result@(T) unwrapping

types/expressions.py - Expression type checking - Binary operators (+, -, *, /, %, ==, !=, <, >, and, or) - Unary operators (-, not) - Function calls - Array access - Struct field access

types/matching.py - Pattern match validation - Exhaustiveness checking - Variant data extraction - Nested pattern support

types/calls.py - Function call validation - Argument count matching - Parameter type compatibility - Return type inference

types/statements.py - Statement validation - Variable declarations - Rebinding - Control flow (if, while, foreach) - Return statements

A field the type does not declare

A name behind a VALUE's dot is a field of that value's type, and one the type does not declare is CE2106, at the read. The pass used to walk past it entirely: the read reached codegen, and the backend was the first thing to notice, answering CE0029 -- tier 1, no file, no line, no caret, and the note that says the fault is a bug in the compiler, for what is a typo (#630). The four backend CE0029 sites stay where they are and go back to being the internal backstop they read as.

reject_unknown_field (passes/types/expressions.py) is deliberately narrow: it answers only a receiver whose type is a STRUCT, which is the one case the inference arm looks a field up in. A namespace member, a bare enum variant (#545), an unresolved type and every non-struct receiver belong to another position, and a false CE2106 there would be worse than the CE0029 it replaces.

A METHOD is not a field, and a bound-method value is deferred to Tier 2, so v.probe with no parentheses is the same refusal with a note that says so. Otherwise the help quotes suggest_member -- the one reader every position that can miss already uses -- or lists what the type does declare.

CE2102 is the same rule one position over: a name behind a TYPE's dot.

Type Checking Examples

Valid:

let i32 x = 42
let i32 y = x + 10  # OK: i32 + i32 → i32

Invalid:

let i32 x = 42
let i32 y = x + "hello"  # ERROR CE2xxx: Cannot add i32 and string

Result Handling:

fn get_value() i32:
    return Result.Ok(42)

# ERROR CE2505: Cannot assign Result@(i32) to i32
let i32 x = get_value()

# OK: Use .realise()
let i32 y = get_value().realise(0)

The lift pass: lambda lifting

File: semantics/passes/lift.py

Each lambda literal becomes a top-level function plus a captured environment. It runs BETWEEN typecheck and borrow, per unit: the lifted body needs the types typecheck stamped, and the lifted function must be borrow-checked like any other.

This pass owns the lambda BODY. A lifted lambda is a function, so its body goes through _validate_function -- the annotate hook -- like every other function's, and the typecheck pass does not descend into a lambda body at all. visit_lambda keeps only what no lifted function carries: the function TYPE the enclosing expression needs, and the capture rules (CE2094), because lift consumes the capture list into the environment struct. Walking the body in both places checked it twice and reported every fault in it twice (#629).

The annotation of one lifted body comes BEFORE the search for a lambda nested in it. The hook is what types a Lambda node, so a nested lambda lifted first carried no parameter types, no captures and no channel, and its own body was never checked.

The environment parameter is a poke borrow, never a peek one. See docs/design/closures.md.

The borrow pass: borrow checking

File: semantics/passes/borrow/ (__init__.py holds BorrowChecker)

Purpose

Enforce memory safety rules for references.

Rules

  1. A reference-typed let is a checked borrow binding (#409; CE2413 retired)
let i32 x = 42
let poke i32 r = x       # a pointer into x's slot, block-scoped
r := r + 1               # x is 43

bind_let_reference (passes/borrow/bindings.py) registers the binding with its full ReferenceType, freezes the owner (CE2412 on a later mutation), and refuses a second poke of the same owner (CE2403) or a peek/poke mix (CE2407). Before #409 the form was rejected as CE2413 rather than compiled as an unchecked alias.

  1. Cannot move/rebind while borrowed
fn borrow(peek i32 x) i32:
    return Result.Ok(x)

fn main() i32:
    let i32 num = 42
    let i32 borrowed = borrow(peek num).realise(0)
    # num := 50  # ERROR CE1007: Cannot rebind while borrowed
    return Result.Ok(0)
  1. Cannot borrow temporaries
# ERROR: Cannot borrow temporary expression
# let i32 x = func(peek (5 + 3))

# OK: Use variable
let i32 temp = 5 + 3
let i32 x = func(peek temp)
  1. Use-after-destroy detection
let i32[] arr = from([1, 2, 3])
arr.destroy()
# println(arr.len())  # ERROR CE2406: Use of destroyed variable 'arr'
  1. A let reading through an owner BORROWS, and consuming or invalidating that borrow is an error (CE2411, CE2412)

A let does not always take ownership of what it binds. Its OWNERSHIP is derived from the provenance of its source expression -- one of three: OWNED (a bare local or a by-value parameter), BORROWED (a match/foreach binding, a peek/poke parameter, or any read through a still-live owner -- a field, an index, a container get-out), or FRESH (a constructor, a call result, .clone(), a literal). See docs/design/ownership-conventions.md for the full classification table.

struct Wrapper:
    i32[] items

fn take(i32[] xs) ~:
    println("{xs.len()}")
    return Result.Ok(~)

fn main() i32:
    let Wrapper w = Wrapper(items: from([1, 2, 3]))
    let i32[] borrowed = w.items  # borrowed BORROWS from w; no allocation happens

    # ERROR CE2411: cannot consume 'borrowed': another owner keeps this value
    # take(borrowed)

    take(borrowed.clone())  # OK: an independent copy
    return Result.Ok(0)

The borrow lasts to the end of the block that declared it. Mutating, freeing, or rebinding w while borrowed is still live is CE2412; handing borrowed itself to a by-value sink is CE2411. A value binding and a reference binding (rule 1) are tracked the same way; the reference binding adds the WRITE path -- a store through it reaches the owner.

Borrow Tracking

Data structures:

active_borrows: Dict[str, BorrowId] = {}
destroyed_variables: Set[str] = set()

On borrow:

if var in active_borrows:
    raise BorrowError("Already borrowed")
active_borrows[var] = borrow_id

On borrow end (function return):

del active_borrows[var]

On destroy:

destroyed_variables.add(var)

On usage:

if var in destroyed_variables:
    raise UseAfterDestroyError("CE2406")
if var in moved_variables:
    raise UseAfterMoveError("CE2405")

Pass Interdependencies

whole program, once:

  collect → docs → externs → libraries → namespaces → ffi-clash → entrypoint
     → instantiate → monomorphize → resolve → finite-types → derive → shadowing
     → effects

then per unit, in one loop:

  scope → typecheck → lift → borrow

Each turn of that loop reports into a reporter of its own, and _merge_unit drains it into the program reporter through in_source_order (internals/report.py). The four passes each walk the unit whole, so what they emit is in PASS order and a reader wants the FILE: a fault the lift pass found in a lambda body would otherwise stand behind every fault the typecheck pass found (#629). A file keeps the place its first diagnostic gave it -- the order the passes reached the files in is information, and alphabetical is not -- and the sort is stable, so two findings on one caret keep pass order.

Dependencies: - docs needs collect (the merged unit table), and must run BEFORE instantiate and monomorphize, or one mistake in a generic's block is reported once per instantiation, and --warn-missing-docs demands a block on every monomorphized clone - externs, libraries and entrypoint need collect (the tables and the signatures) - namespaces needs collect (a unit's declarations, the FFI table, the registry) and libraries (a binary library's declarations arrive from a manifest and nowhere else); scope and typecheck read the table it builds - instantiate needs libraries (a library template must be visible to instantiate at the consumer) - monomorphize needs instantiate (the set of instantiations to generate) - resolve needs monomorphize (every struct and enum a generic produced must exist) - finite-types needs resolve (the resolved field types), and STOPS the analysis on failure - derive needs finite-types (a cycle would otherwise reach its topological sort as CE0128) - shadowing needs derive (the auto-derived pair must be registered before a collision can be seen) - scope needs collect (function signatures), and runs AFTER every whole-program pass, because the body walks need concrete, monomorphized types - typecheck needs resolve and scope - lift needs typecheck (the lifted body reads its stamps) - borrow needs typecheck (the stamps) and effects (the cross-unit summary)

Error Examples by Pass

scope: - CE1003: Undefined variable - CE2405: Use of moved variable

typecheck: - CE2xxx: Type mismatch - CE2502: .realise() wrong argument count - CE2505: Assigning Result@(T) without handling

borrow: - CE1007: Cannot rebind while borrowed - CE2406: Use of destroyed variable


See also: - Architecture - Overall compiler design - Backend - Code generation details