Semantic Analysis
Symbol Tables & Resolution
The analyzer walks the AST once per concern, consulting a symbol table that mirrors the language’s scoping rules.
Figure 1 — The scope stack answers “what does this name mean here?” exactly as Semantics & Scoping specified.
The Resolution Walk
For each name occurrence: search the innermost scope, then outward; annotate the AST node with the winning binding. Resolve once, store the answer, never search twice.
Duplicates and Shadowing
Report duplicate declarations in one scope; allow shadowing only where the design document did. The rule from Semantics & Scoping is now code, not prose.
Type Checking
Rules from Types & Values
For each expression node: infer its type, then check it against the operator’s domain. Dynamic modes defer this to runtime; static modes run it here, in the middle of the pipeline, and stop a bad program before it ever executes.
Inference in a Nutshell
Without writing a solver: propagate types up from literals and parameters, unify at operators, report the first node with no consistent type. For small DSLs this bottom-up synthesis is enough.
Desugaring
Remove the sugar the surface syntax promised: turn loops into the semantics’ canonical while-form, expand shorthand into explicit rules. Later passes then see exactly one canonical shape.
Sugar Discipline
Every sugar needs a differential test: the desugared form must evaluate identically to hand-written equivalents.
Semantic Errors
Report in Batches
Undefined names, duplicate declarations, type mismatches, and violated domain rules are reported here, following the Errors & Diagnostics style: location, expectation, help. Collect all findings in one pass instead of stopping at the first.
Example: A Resolution Pass
The heart of analysis in one commented function: push scopes, resolve names, and decorate the tree.
resolver.py (commented)
# resolver.py — one walk, one concern: names must resolve
class Resolver:
def __init__(self):
self.scopes = [{}] # scope stack; outermost first
def declare(self, name):
self.scopes[-1][name] = True # remember: declared here
def resolve(self, name):
# search news to old scopes; shadowing falls out of the order
for scope in reversed(self.scopes):
if name in scope:
return name # the winning binding
raise NameError(f"undefined name {name!r}")
def enter(self): self.scopes.append({})
def exit(self): self.scopes.pop()
How to Read It
Entering a block pushes a scope; exiting pops it; resolve walks outward as Semantics & Scoping specified. Type checking and domain-rule checks are the same shape with different payloads — that uniformity is what makes the analyzer testable.
Next Steps
Continue Phase 2
A checked tree is a good IR. See how it is lowered and shaped: Intermediate Representations, then Bytecode & Codegen.