M5 is where the pieces become a product. The DefinitionCompiler takes a snapshot
of the builder state and runs it through a pipeline that ends in a frozen,
immutable GenerationPlan. It tests a choice I made while designing Build():
every failure carries a LIE code from a registry, never a bare exception, and
the compiler reports every detectable error, not just the first.
The pipeline
- Validate options (empty name → LIE005).
- Resolve member expressions via the M3 resolver (bad shape → LIE001).
- Group rules per member and detect conflicts (LIE002).
- Plan construction (LIE004).
- Infer every unruled member (M4).
- Validate results, a required member that resolves to "unsupported" → LIE003.
- Compile the reachable child graph, cycle-safe.
- If any error-severity diagnostic exists, throw; otherwise freeze the plan.
Everything appends to one DiagnosticBag, and the pipeline runs to completion so
the exception carries the full list. LieDefinitionException.Message is composed
to a contract, the first error, prefixed with the model and suffixed with the
remaining count, so naive logging is already useful:
Definition for Car is invalid: Conflicting member rules for Car.Year (LIE002).
(2 more errors; see Diagnostics.)
Constructor selection
ConstructorPlanner implements the order I chose during design: a single
[LieConstructor]-marked ctor wins; otherwise the public ctor with the most
resolvable parameters; a parameterless ctor is preferred only on a tie;
otherwise it's ambiguous (LIE004). "Resolvable" is decided by running the
inference pipeline against a synthetic member built from each parameter, so
constructor parameters and properties share one inference implementation by
construction, which is exactly the promise the API makes. The selection matrix
(attribute, most-resolvable, parameterless tie, ambiguous failure, positional
record) is a five-case test.
Two-phase child compilation, the easy way
The reachable type graph, nested members, collection element types, is compiled
in the same Build(). Recursive graphs (Employee.Manager is an Employee) have
to terminate. The architecture describes "shells filled in two phases"; I got the
same guarantee more simply with a visited-set worklist over types. A
NestedSource holds the child type, not a direct plan reference, and the runtime
resolves it through a ReachablePlans dictionary. So compilation is just: pop a
type, build its plan (which enqueues its children), mark it visited, repeat.
Employee enqueues Employee, sees it's already building, and stops. One pass, no
infinite recursion, no mutable shells. A self-type reference emits LIE009 (Info,
not an error), the build still succeeds.
The registry is executable, too
The diagnostic codes live in an internal registry, code, default severity, and
title. A conformance test asserts the table matches the diagnostic set I designed.
It is the same move as the inference catalog: turn an intended shape into
something the build verifies.
An honest deferral
There's a wrinkle in the milestone order I chose: the compiler needs generators
for inferred scalars, but the real generators (names, emails, lorem) arrive in
M7. M5 therefore emits placeholder delegates that throw "bound in M7."
Generation is still stubbed, so they are never invoked, while the plan
structure remains fully correct and testable. The MemberReportData captures
the real inference result; only the executable function waits.
What's next: M6, the Generation Runtime
The plan exists; nothing runs it. M6 builds GenerationOperation, the per-call
engine that traverses the plan in lifecycle order (construct, populate in member
order, derive in registration order), checks cancellation at the documented
checkpoints, wraps user-delegate failures exactly once with the right
GenerationPhase, and handles depth and cycles. And it surfaces the question I'd
been quietly dreading: if generators are M7, what exactly can M6 generate?

Top comments (0)