Part 1 (docs/plan-two-phase.md) gave the compiler an AST: parsing now
builds nodes and Lower emits from them. What is still not split
is semantic analysis: name binding, type resolution and all 2xx
diagnostics still run during parse, inside the grammar's actions.
This document designs the second split, which removes the last forward-reference machinery and makes forward references ordinary.
Forward references — a body naming a global, a CONST naming a later
type — are still special-cased:
Design tolerates an undeclared name inside a
procedure, creates a placeholder alias (SymTab.FwdVarRef) and emits
an @ marker;ClPtr-shaped, so the type checks must be made
lenient (IsFwdVar guards in Assignable, ArithCheck,
OrdCheck, Expr);FwdVarFlush) reports the ones that never
resolve (201).All of that exists only because checks run before the module's later
declarations are known. A resolve/check pass that runs after parsing
would let every reference bind first and check second, so the mechanism
could be deleted. It would also unlock what depends on it: aggregate
(SET/record) values in CONST, forward procedures without a
DEFINITION, and a cleaner statement-context story.
compiler/src/M2.atg is 3,489 lines with 171 SemError sites and
~1,061 SymTab.* call sites (190 distinct procedures) across its
production actions. Lower.mod (2,337 lines) emits from the AST and
already does some late binding (e.g. it resolves a forward
designator's name at lower time), but it is an emitter, not a
checker: it assumes the SymTab entries are already correct.
So the work is a relocation: move the semantic decisions out of the per-production actions into a walk over the AST, run after the unit's declarations are complete.
per unit:
1. Parse -> AST + a *declaration* skeleton in SymTab
(names and kinds only; no type checks, no forward
errors)
2. Resolve/Check-> walk the AST: bind identifiers to symbols, resolve
and complete types, check assignments/arity/2xx,
emit all diagnostics
3. Lower -> walk the AST (unchanged) and emit QBE
4. Emit -> unchanged (buffered image, EndModule)
The crucial ordering change: step 2 runs after the whole unit (and its imports) are parsed, so a forward reference simply binds to a symbol that now exists.
Grouped by where the check lives today:
| Category | Today (grammar actions) | Notes |
|---|---|---|
| Declaration entry | SymTab.Enter/EnterPending/FixPending in ConstDecl/TypeItem/VarDecl/ProcHeading |
keep in parse: names/kinds are the skeleton |
| Type constructor checks | Type/ArrayType/RecordType/SetType/PointerType actions |
move: bounds, element/base resolution, 230s |
| Expression typing | Expr/SimExpr/Term/Fact/Design sets t on every node |
move: fill AST.SetTy, ArithCheck/RelCheck/Assignable |
| Statement checks | AssOrCall/IfStat/WhileStat/ForStat/CaseStat/ReturnStat |
move: lvalue/type/232/214 checks |
| Call checks | ArgList/ActParam (arity 233, formal type, VAR-ness) |
move: against the bound callee |
| Class/method checks | MethodHeading/ClassItem/method binding in Design |
move: method lookup, vtable slots |
| Forward tolerance | Design placeholder + IsFwdVar + FwdVarFlush |
delete once 1–7 are late |
Lower currently re-derives a little of this (it calls ClassOf,
ArrayElem, ...). After the split, Lower should read the types the
checker stored (AST.Ty) instead of re-deriving, so the two agree.
NkIdent/NkDesignator gets its resolved symbol
recorded on the node (a new AST field, e.g. sym/uid), not
re-looked-up repeatedly. A designator that never binds is 201
here (replacing FwdVarFlush).ty slot; the walk sets
it. Aggregates get their descriptor type; sets their word count.Enter).SemError(n) for 210–222, 231–235 move to the
walk. Parse-time errors stay: 200 (duplicate), 201 for a declared
name that is malformed, 202 (module name).Keep the old (parse-time) check path until the new one passes, exactly as the L7 flip did.
Branch resolve-check off v3-generics-inc2.
proc Resolve(n: AST.Node) (and helpers) in a new
Check.def/Check.mod; call it from the driver between Parse and
Lower.LowerUnitNow, gated by a flag. It starts as a no-op.
(done — compiler/src/Check.*, gate -noresolve, 50df067.)Expr-family typing and
Design name binding into Check; store the resolved symbol and
type on the AST node (new AST field); make Lower read them.
Forward references in a proc body (see §11) are bound here, not
patched.Design
forward branch, FwdVarRef/FwdVarName/FwdVarType/
FwdVarResolve, IsFwdVar and its guards, the grammar's
FwdVarNote/FwdVarFlush, and the @ marker.-resolve/legacy byte-compare over the suite to
prove equivalence, then remove the flag and the old path.(Step numbering changed 2026-10-15 — see §11. Type-constructor checks are not a prerequisite and stay parse-time.)
./bootstrap/fixpoint.sh — byte-identical stage2/stage3.
The hard gate, as always.run_tests.sh (190/190 today) after every increment.tools/v3-corpus/corpus.sh — the undeclared identifier /
forward-reference buckets should shrink; track compile-OK counts..ssa for the whole suite should match until a feature deliberately
changes.Check.mod
and M2.atg until the flip, so Check must respect
declaration-before-use and the array/VAR-formal limitations that bit
us in inc2.Lower reading unchecked types during the transition — keep
Lower deriving what the checker has not yet moved.SymTab.FwdVarRef, FwdVarName, FwdVarType, FwdVarResolve,
IsFwdVar; the grammar's FwdVarNote/FwdVarFlush/nFvarRefs and the
Design forward branch; the @ marker in Lower; the IsFwdVar
guards in SymTab.{Assignable,ArithCheck,UnaryCheck,OrdCheck,EqCheck}
and in Expr.
Branch resolve-check off v3-generics-inc2 (ba9d042). Baseline:
suite 190/190, fixpoint OK (3,472,663 bytes). Start with §6 step 1.
Measured while scaffolding (branch resolve-check):
NkArray/NkRecord/NkPointer/NkEnum/NkTypeIdent/NkProcType
(0 sites in M2.atg and Lower.mod); only NkSubrange and (one)
NkSet appear. Constructed types live only in SymTab (resolved
TypeIndex), and Lower reads them from there. So the original §6
step 2 ("walk the type AST") had no data to walk and would have
needed the parser extended to build a type AST first — a large
prerequisite that duplicates SymTab for no two-phase benefit.Design's fallback fires only
inside a procedure (SymTab.InProc()), only for a name not found,
and assumes a module-level variable declared later. It creates a
placeholder symbol (FwdVarRef, ClPtr-shaped) plus an @-marker
node, and FwdVarFlush (module end) patches the placeholder to the
real symbol/type before Lower runs. Empirically:
PROCEDURE P; BEGIN x := 7 END P; VAR x: INTEGER; compiles, while a
never-declared y is 201.FwdVar* requires binding after the
unit's declarations are all parsed — i.e. the expression/statement
walk (new §6 steps 2–3) — and not the type-constructor checks,
which can stay parse-time indefinitely. The AST does carry
expression structure (NkDesignator/NkSelector/NkBinExpr/
NkIdent/literals), and every type the walk needs is available in
SymTab, so a walk keyed on the AST + SymTab is the tractable target.AST: a per-node resolved TypeIndex
(exists — AST.SetTy/AST.Ty) and, for an identifier/designator
base, its resolved symbol (needs a new field, or reuse the existing
node-id slot) so Lower can stop re-deriving.f34ca98) — the forward-reference resolution half
moved out of the grammar into Check (NoteForward/FlushForward,
driven from Resolve, which the driver calls for every unit). To run
it post-parse, resolution is now scope-independent: SymTab records
the scope at FwdVarRef time (fwdVarScope) and FwdVarResolve
walks up from it via a new FindScope (Find is a thin wrapper).
The grammar's FwdVarNote/FwdVarFlush, nFvarRefs/fvarId/
fvarSlot and its four flush call sites are gone; SymTab.FwdVarType
was dead and is removed.Check.NoteForward/
FlushForward (replaced by real binding in the walk).Note: -noresolve now disables the forward fix-up too (it turns
off the whole Check pass); on forward-free code it stays
byte-identical, so the suite gate is unaffected.
2026-10-15 (7ca434b) — revised step 2 proper (the expression
walk) turned out to be blocked: Lower.LowerExprFull interleaves
type construction with emission (it calls SymTab.NewSet /
NewStr / NewUStr while emitting), so there is no clean
"type-only" path to share with a checker, and the AST carries no
per-expression type to walk. Took a more direct deletion instead:
the forward operand is now InvalidType, so the generic "skip
checks on an unknown type" rule (which every check already has)
replaces the purpose-built IsFwdVar leniency. Deleted
SymTab.IsFwdVar, FwdVarType, the fwdVarT table and the five
guards; FwdVarRef no longer allocates a placeholder alias.
Suite 190/190; fixpoint OK (3,466,859 bytes).
Remaining for step 4: SymTab.FwdVarRef/FwdVarName/
FwdVarResolve and Check.NoteForward/FlushForward still exist,
but they now only resolve a name (no patching). To delete them,
Design must stop deferring at all — which needs the expression and
statement checks moved out of the actions, i.e. the walk, still
blocked on Lower's type/emit split.
2026-10-15 (5703b27, step 4a) — expression types are now stored
on the AST (SetTy at the Expr/SimExpr/Term/Fact levels). Findings
that shape the split:
Lower.LowerExprFull constructs types while emitting
(NewStr/NewUStr/NewSet/NewSubR) and those do not dedup
(NewDesc always allocates). A type must therefore be constructed
exactly once and cached on the node; the parser already computes
(and for literals constructs) the same type at parse time — now
stored — so Lower can read AST.Ty(n) instead of reconstructing,
which also removes a duplicate construction per string/set literal.AST.Ty defaults to -1 (= InvalidType), so "unset/unknown" is
distinguishable from a real index; Lower can read it with a
fallback while the split is in progress.tests/t_fwdvar.mod
(and Showcase22) rely on a procedure body referencing a
module-level variable declared later — including through an
array-index selector (label[0] := "o"). An experiment that
replaced the tolerance with an immediate 201 failed exactly those
two tests. So FwdVar* can only go once Design's binding is
genuinely late, and the walk must keep forward refs with selectors.LowerDesign (~line 690), which Check would run
after resolving forward references and store on the node
(AST.SetTy) for Lower to read. Designator nodes are the only
expressions whose parse-time type is -1 (forward/undefined).2026-10-15 (192ad55, step 4b) — the designator resolver exists:
Check resolves a forward designator's full type — the base symbol
(from the scope recorded at reference time) plus its selectors, via
SelType over SymTab.FieldType/ArrayElem/PtrBase — and stores
it on the node with AST.SetTy. SymTab.FwdVarResolve now also
returns the resolved type, and Design attaches the designator node
to the forward note. Verified with a temporary differential
instrument in LowerDesign (which computes the same type
independently): clean across the suite, and it fires (299) when the
resolver is deliberately broken. New regression test t_fwdsel.mod
(forward ref through an array-index selector). Suite 191/191;
fixpoint OK (3,474,750 bytes).
Next: forward designators are now typed, but an expression
containing one (e.g. arr[0] + arr[1]) is still typed InvalidType
at parse time — the walk must propagate the resolved types up the
tree and then take over the statement/expression checks. Only then
can Design stop deferring and FwdVar* go.
2026-10-15 (9dc2ed7, step 4c) — resolved types now propagate up
the expression tree: Check.PropNode walks the unit post-order and
EvalExpr re-types any node still -1, combining its operands by the
operator rules (reusing SymTab.ArithCheck/RelCheck/InCheck/
BoolCheck). Leaf kinds must be skipped — their child 0 is a text
index, not a node (descending into it segfaulted). First half of the
Lower split too: string and set literals now read the type the
parser stored instead of constructing a second one. Verified by the
differential instrument (compared by class); regression tests
t_fwdsel.mod and t_fwdprop.mod; suite 192/192.
Remaining: the checks themselves (assignment, control flow,
calls) are still parse-time and lenient for forwards, so Design
still defers; moving them into the walk is what finally lets
FwdVar* go. The instrument also exposed a pre-existing, harmless
divergence: a procedure value's type is ClProc at parse time but
ClInvalid in Lower.
2026-10-15 (8efae2e, step 4d) — the first check moves to the
walk: an assignment whose operand was a forward reference is now
type-checked after resolution. PropNode returns "touched by a
forward reference" (a forward designator node is marked Op=1; a node
PropNode re-types also counts), and CheckAssign re-checks such an
assignment (Assignable / class class). Previously n := "abc" with
a later VAR n : INTEGER was silently accepted. Non-forward
assignments keep the parser's check, so there are no duplicate
diagnostics and -noresolve still checks fully. New expect_fail
test t_bad_fwd.mod; suite 193/193.
Pattern for the remaining checks: the same shape — a check the
parser had to skip for a forward operand is re-run in PropNode for a
touched subtree (control-flow conditions, RETURN, arity, …).
Architectural conclusion: FwdVar*/NoteForward cannot be
deleted this way. Even with every check moved, Design must still
bind a not-yet-declared name — which needs the compiler to parse
declarations before bodies (a genuinely two-phase parse). The
resolve/check pass improves diagnostics for forward references and
gives the type layer; the deletion needs the parse restructure.