Section 20A: Compile-Time Struct Construction
Status: Not Started (verified 2026-03-29 — all file paths, line numbers, line counts, ExprKind match sites, arena APIs, error code ranges, dependency chain, and proposal confirmed accurate)
Goal: $construct<T> and $construct_partial<T: Default> expand during monomorphization to direct ExprKind::Struct literals (or ExprKind::Call for newtypes) — identical codegen to hand-written T { field1: val1, ... }. The existing struct pipeline (type checker completeness check, canonical lowering, eval, ARC, LLVM) handles everything downstream. Adding the ExprKind::Construct variant requires match arms in 11 files with exhaustive ExprKind dispatch, but no semantic changes to downstream phases (eval, ARC, LLVM operate on CanExpr, which never sees Construct).
Context: The approved compile-time-construction-proposal.md (2026-03-26) completes the compile-time reflection story by adding construction to complement the inspection primitives from Section 20 (fields_of, $for, splice). Without construction, generic deserialization is impossible — you can iterate fields and parse values, but cannot assemble them into a typed struct. The flagship use case is a pure Ori JSON parser (pub def impl FromJson) that uses $construct<Self> with $for field in fields_of(Self).
Proposal: proposals/approved/compile-time-construction-proposal.md
Reference implementations:
- Zig
src/Sema.zig:19438-19750:zirStructInit()— completeness checking at struct literal level after inline-for expansion, optional default field values viastructFieldDefaultValue() - C++26 P2996:
template forexpansion produces struct initializer list — completeness enforced by aggregate initialization rules
Depends on: Section 20 (Compile-Time Reflection — provides fields_of(T), $for, $FieldMeta, $if, splice, monomorphization expansion infrastructure).
Architecture
$construct<T>( ExprKind::Construct
$for field in fields_of(T) { type_param: Name,
yield (field, value) args: ExprId,
) is_partial: false }
| |
v (monomorphization) v
$for already expanded Extract ($FieldMeta, value) pairs
by Section 20 machinery from expanded args expression
| |
v v
Match FieldMeta.name to struct fields
Completeness check (E0470-E0472)
For partial: fill missing with Default.default()
|
v
Struct: ExprKind::Struct { name, fields }
Newtype: ExprKind::Call { func, args }
(normal struct literal or ctor call — existing pipeline)
|
v
infer_struct (ori_types)
[unchanged — receives well-formed struct literal]
|
v
ori_canon: ExprKind::Struct → CanExpr::Struct
[unchanged — normal canonicalization]
|
+------------+------------+
| | |
v v v
eval_can_struct lower_struct build_struct
(ori_eval) (ori_arc) (ori_llvm)
[unchanged] [unchanged] [unchanged]
Key insight: $construct is an expansion-phase construct. After monomorphization, no Construct nodes remain in the IR. The eval, ARC, and LLVM phases see only plain CanExpr::Struct (via normal canonicalization of ExprKind::Struct) — zero changes needed in those downstream phases. However, adding a new ExprKind::Construct variant does require match arms in all 11 files with exhaustive ExprKind dispatch (see 20A.1 for the full list). The variant’s 9 bytes of payload (Name + ExprId + bool) fits within the existing 24-byte ExprKind size budget (static_assert_size!(ExprKind, 24) in ori_ir/src/ast/expr.rs:508).
20A.1 Parser: $construct and $construct_partial Syntax (verified 2026-03-29 — 11/11 items not-started, all file paths and line numbers confirmed accurate)
File(s): compiler/ori_ir/src/ast/expr.rs, compiler/ori_parse/src/grammar/expr/primary/literals.rs
Goal: Parse $construct<T>(expr) and $construct_partial<T>(expr) as compile-time intrinsic calls, producing a new ExprKind::Construct AST node.
IR Representation
-
Add
ExprKind::Constructvariant toExprKind(compiler/ori_ir/src/ast/expr.rs)/// Compile-time struct construction: $construct<T>(field_pairs) /// Expands to ExprKind::Struct during monomorphization. Construct { /// The type to construct (resolved to concrete type at monomorphization) type_param: Name, /// Single expression producing compile-time [($FieldMeta, value)] pairs /// Typically a $for...yield expression args: ExprId, /// true = $construct_partial (allows missing fields, requires T: Default) is_partial: bool, }, -
Visitor/walker support — add match arm in all 11
ExprKindexhaustive matchescompiler/ori_ir/src/visitor/walk_expr.rs— add to single-child group:ExprKind::Construct { args, .. } => { visitor.visit_expr_id(*args, arena); }compiler/ori_ir/src/ast/expr.rs— Debug fmt match (line 368): addExprKind::Construct { type_param, args, is_partial } => write!(f, "Construct({type_param:?}, {args:?}, partial={is_partial})")compiler/ori_types/src/infer/expr/mod.rs— type inference dispatch (line 97 match): return typeTresolved fromtype_param, delegate expansion to Section 20.3 expansion sub-phase (theConstructnode must be expanded beforeinfer_structruns on the replacement). Concrete arm:ExprKind::Construct { type_param, args, is_partial } => infer_construct(engine, arena, *type_param, *args, *is_partial, span)— addinfer_constructfunction that resolves the type param, infers args, and returns the resolved struct typecompiler/ori_canon/src/lower/expr.rs— canonicalization dispatch (line 32 match):ExprKind::Construct { .. } => unreachable!("$construct must be expanded before canonicalization")(defense-in-depth)compiler/ori_fmt/src/formatter/inline.rs(520 lines — at BLOAT limit) — add arm: emit$construct<+ type name +>(+ inline args +)(or$construct_partial<...>); add to leaf-like group nearConsthandlingcompiler/ori_fmt/src/formatter/broken.rs(511 lines — at BLOAT limit) — mirror inline arm but delegate args toemit_broken(args)compiler/ori_fmt/src/formatter/stacked.rs— mirror broken arm but delegate args toemit_stacked(args)compiler/ori_fmt/src/width/mod.rs(579 lines — BLOAT) — add: width ="$construct<".len()+ type_name_width +">(".len()+ args_width +")".len()(adjust for_partialvariant)compiler/oric/src/ir_dump/expr.rs(617 lines — BLOAT) — line ~124 match: addExprKind::Construct { type_param, args, is_partial } => { writeln!(out, "{indent}$construct{partial}<{name}>{ty}", partial = if *is_partial { "_partial" } else { "" }, name = interner.lookup(*type_param)).unwrap(); dump_expr(out, *args, ...); return; }compiler/oric/src/ast_dump/expr.rs— main match at line 33 (exhaustive, no wildcard): addExprKind::Construct { type_param, args, is_partial } => { writeln!(out, "{indent}$construct{partial}<{name}>", ...); dump_expr(out, *args, ...); return; }— the second match at line 536 uses_ =>and needs no changecompiler/ori_parse/src/incremental/copier.rs(1595 lines — BLOAT) — add to copy_expr match (line ~112):ExprKind::Construct { type_param, args, is_partial } => { let new_args = self.copy_expr(args, new_arena); ExprKind::Construct { type_param, args: new_args, is_partial } }- Adding the variant to
ExprKind(which derivesCopy, Clone, Eq, PartialEq, Hash) is safe:Name,ExprId, andboolall implement these traits - Size budget: variant payload is 9 bytes (
Name(u32)+ExprId(u32)+bool), well within the 24-byteExprKindbudget enforced bystatic_assert_size!(ExprKind, 24)atori_ir/src/ast/expr.rs:508 - Non-exhaustive matches (no changes needed):
ori_fmt/src/rules/*.rs,ori_fmt/src/width/control.rs,ori_arc/src/decision_tree/flatten.rs,oric/src/ast_dump/expr.rsinline match at line 536 (_ =>wildcard),ori_canon/src/desugar/calls.rsat line 141 (_ =>wildcard) (added from verification finding 3)
Parser Implementation
-
Add
parse_constructin parser (compiler/ori_parse/src/grammar/expr/primary/literals.rs)- Entry point:
$constructdetected inparse_misc_primary()(atliterals.rs:256) whereTokenKind::Dollaris currently handled - Currently the
$arm (line 256-263) does: advance past$,expect_ident()to get name, produceExprKind::Const(name). To support$construct, the ident text must be inspected afterexpect_ident()returns — if name text equals"construct"or"construct_partial", callparse_construct()instead of producingConst. Useself.interner.lookup(name)to get the text. - Section 20 will add:
$+for→parse_comp_for(),$+if→parse_comp_if()(these check the ident text after the sameexpect_ident()call) - New: name text
"construct"→parse_construct(false, ...), name text"construct_partial"→parse_construct(true, ...) - The fast-path at
primary/mod.rs:137(TAG_DOLLARbranch) already routes toparse_misc_primary()— no additional wiring needed there
// Pseudocode — uses committed!() macro and self.cursor.expect() per parser conventions fn parse_construct(&mut self, is_partial: bool, start_span: Span) -> ParseOutcome<ExprId> { // Parse <T> — type argument committed!(self.cursor.expect(&TokenKind::Lt)); let type_name = committed!(self.cursor.expect_ident()); committed!(self.cursor.expect(&TokenKind::Gt)); // Parse (expr) — single argument expression committed!(self.cursor.expect(&TokenKind::LParen)); let args = self.parse_expr()?; committed!(self.cursor.expect(&TokenKind::RParen)); let span = start_span.merge(self.cursor.previous_span()); ParseOutcome::consumed_ok(self.arena.alloc_expr(Expr::new(ExprKind::Construct { type_param: type_name, args, is_partial, }, span))) } - Entry point:
-
Wire into primary expression dispatch
- In
parse_misc_primary()(literals.rs:256-263): the currentTokenKind::Dollararm is:
Replace with: after gettingTokenKind::Dollar => { self.cursor.advance(); let name = committed!(self.cursor.expect_ident()); let full_span = span.merge(self.cursor.previous_span()); ParseOutcome::consumed_ok(self.arena.alloc_expr(Expr::new(ExprKind::Const(name), full_span))) }name, lookup the text viaself.interner.lookup(name). Match on text:"construct"→return self.parse_construct(false, span)"construct_partial"→return self.parse_construct(true, span)- anything else → fall through to existing
ExprKind::Const(name)path
- Coexists with Section 20’s
$forand$ifdispatch (which will check for"for"and"if"text in the same match)
- In
Tests
TDD: Write failing test matrix BEFORE implementation. Verify tests fail first, then implement, then verify tests pass unchanged.
-
Write failing parse unit tests in
compiler/ori_parse/src/grammar/expr/primary/literals/tests.rs— FIRST, before any implementation:Type x pattern matrix:
Input Expected $construct<User>($for field in fields_of(User) yield (field, 42))ExprKind::Construct { is_partial: false, type_param: "User" }$construct_partial<Config>($for field in fields_of(Config) yield (field, value))ExprKind::Construct { is_partial: true, type_param: "Config" }$construct<User>([($name_field, "Alice"), ($age_field, 30)])is_partial: false, list literal arg$construct<T>(expr)generic type param T$construct<Pair>(single_pair)single simple expression arg Edge cases:
$construct<T>(())— unit expression as arg (boundary)$construct<T>($for field in fields_of(T) yield (field, $construct<Inner>(inner_pairs)))— nested$constructinside arg
Error cases:
$constructwithout<T>— parse error (missing type param)$construct<>()— empty type param error$construct<T>without()— missing args error$construct_partialwithout<T>()— same error pattern as$construct
Roundtrip: verify
ori fmton$construct<T>(expr)and$construct_partial<T>(expr)reproduces source unchanged -
Verify failing tests fail — run
timeout 150 cargo t -p ori_parseand confirm all new tests fail before implementing -
Spec tests in
tests/spec/reflection/construct/parse/:parse_construct.ori—$construct<User>(pairs)parses without error viaori checkparse_construct_partial.ori—$construct_partial<Config>(pairs)parses without errorparse_construct_error.ori—#compile_failtests for malformed syntax
-
Semantic pin:
$construct<T>(expr)parses toExprKind::Construct, notExprKind::Call— Rust unit test that asserts on the enum discriminant; only passes with the new variant (would fail if$constructwere parsed as a regular function call) -
Verify all tests pass in debug and release —
timeout 150 cargo t -p ori_parseandtimeout 150 cargo b --release -
/tpr-reviewpassed — independent review found no critical or major issues (or all findings triaged) -
/impl-hygiene-reviewpassed — hygiene review clean. MUST run AFTER/tpr-reviewis clean. -
Subsection close-out (20A.1) — MANDATORY before starting the next subsection. Run
/improve-toolingretrospectively on THIS subsection’s debugging journey (per.claude/skills/improve-tooling/SKILL.md“Per-Subsection Workflow”): whichdiagnostics/scripts you ran, where you addeddbg!/tracingcalls, where output was hard to interpret, where test failures gave unhelpful messages, where you ran the same command sequence repeatedly. Forward-look: what tool/log/diagnostic would shorten the next regression in this code path by 10 minutes? Implement improvements NOW (zero deferral) and commit each via SEPARATE/commit-pushusing a valid conventional-commit type (build(diagnostics): ... — surfaced by section-20A.1 retrospective—build/test/chore/ci/docsare valid;tools(...)is rejected by the lefthook commit-msg hook). Mandatory even when nothing felt painful. If genuinely no gaps, document briefly: “Retrospective 20A.1: no tooling gaps”. Update this subsection’sstatusin section frontmatter tocomplete. -
/sync-claudesection-close doc sync — verify Claude artifacts across all section commits. Map changed crates to rules files, check CLAUDE.md, canon.md. Fix drift NOW. -
Repo hygiene check — run
diagnostics/repo-hygiene.sh --checkand clean any detected temp files.
20A.2 Monomorphization: Expansion to Struct Literal (verified 2026-03-29 — 14/14 items not-started, arena API references confirmed accurate)
File(s): compiler/ori_types/src/infer/expr/calls/monomorphization.rs (or new sibling expansion.rs), compiler/ori_types/src/infer/expr/structs/mod.rs
Goal: When the monomorphizer encounters ExprKind::Construct with concrete T, expand it to a direct ExprKind::Struct literal (for structs) or ExprKind::Call to the newtype constructor (for newtypes) after validating completeness.
Depends on: 20A.1 (parser), Section 20.3 ($for expansion — the args expression is already expanded by this point).
Expansion Logic
Important context: The current monomorphization.rs only records MonoInstance records (type substitution maps for generic functions). It does NOT walk or transform the expression AST. Section 20.3 (part of the parent Section 20) will add a new AST expansion sub-phase to the monomorphization subsystem — this is the infrastructure that $construct expansion relies on. The expansion sub-phase will walk expression trees during type checking (after concrete types are resolved), rewriting compile-time constructs into plain AST nodes before canonicalization.
The expansion happens in the same expansion sub-phase that handles $for and $if (Section 20.3). The expansion pass processes expressions in pre-order: $for expansions complete before parent $construct nodes are expanded. This ensures that when expand_construct() receives the args expression, all nested $for nodes have already been fully expanded into concrete value lists.
Critical dependency on Section 20.3 infrastructure: The expand_construct() pseudocode below calls self.arena.alloc_expr() and self.arena.alloc_field_inits(). In the current architecture, the ExprArena is owned by Salsa and typically immutable during type checking. Section 20.3 must provide a mechanism for arena allocation during expansion — likely either (a) a separate expansion arena that is merged post-expansion, or (b) mutable access to the main arena during the expansion sub-phase. This is a load-bearing architectural decision that 20A.2 consumes but does not define. If Section 20.3 is not yet implemented when work on 20A.2 begins, the expansion logic should be written as a standalone function with an &mut ExprArena parameter, ready to be wired into whatever arena-access mechanism Section 20.3 provides.
- Add
expand_constructto monomorphization (monomorphization.rs)fn expand_construct( &mut self, type_param: Name, // resolved to concrete struct type args: ExprId, // expanded $for result: list of ($FieldMeta, value) pairs is_partial: bool, span: Span, ) -> Result<ExprId, TypeCheckError> { // 1. Resolve T to concrete struct type from MonoInstance let struct_type = self.resolve_type(type_param)?; let struct_def = self.get_struct_def(struct_type)?; // 2. Extract ($FieldMeta, value) pairs from expanded args let pairs = self.extract_field_value_pairs(args)?; // 3. Match each $FieldMeta.name to struct field let mut field_inits: Vec<FieldInit> = Vec::new(); let mut seen_fields: FxHashSet<Name> = FxHashSet::default(); for (meta, value_expr) in &pairs { let field_name = meta.name; // interned Name // E0471: duplicate if !seen_fields.insert(field_name) { return Err(duplicate_field_in_construct(span, field_name)); } // E0472: field doesn't exist if !struct_def.has_field(field_name) { return Err(field_not_in_type(span, field_name, struct_type)); } field_inits.push(FieldInit { name: field_name, value: Some(*value_expr), span }); } // 4. Completeness check let provided: FxHashSet<Name> = seen_fields; let missing: Vec<Name> = struct_def.fields() .filter(|f| !provided.contains(&f.name)) .map(|f| f.name) .collect(); if !missing.is_empty() { if is_partial { // Fill missing with Default.default() for field_name in &missing { let default_call = self.synthesize_default_call(field_name, struct_def)?; field_inits.push(FieldInit { name: *field_name, value: Some(default_call), span }); } } else { // E0470: missing field return Err(missing_field_in_construct(span, struct_type, missing)); } } // 5. Emit the appropriate ExprKind based on type kind if struct_def.is_newtype() { // Newtypes: emit ExprKind::Call to the newtype constructor // fields_of(Newtype) returns [{ name: "inner", index: 0 }] // so field_inits has exactly one entry for "inner" assert!(field_inits.len() == 1, "newtype must have exactly one field"); let inner_value = field_inits[0].value.expect("newtype field must have value"); let ctor_ref = self.arena.alloc_expr(Expr::new( ExprKind::Ident(struct_def.name), span)); // NOTE: ExprRange is built via alloc_expr_list_inline (NOT alloc_expr_range, which does not exist) // See: ori_ir/src/arena/range_builders.rs:82 let args = self.arena.alloc_expr_list_inline(&[inner_value]); Ok(self.arena.alloc_expr(Expr::new(ExprKind::Call { func: ctor_ref, args, }, span))) } else { // Structs: emit ExprKind::Struct — normal struct literal // NOTE: FieldInit is Clone (not Copy) — pass Vec directly let field_range = self.arena.alloc_field_inits(field_inits); // returns FieldInitRange (arena/range_builders.rs:199) Ok(self.arena.alloc_expr(Expr::new(ExprKind::Struct { name: struct_def.name, fields: field_range, }, span))) } }
Visibility Rules
- Enforce visibility at expansion time
$construct<T>follows the same visibility rules as struct literal constructionfields_of(T)already returns only public fields (Section 20.1)- Completeness check compares against ALL fields of
T(including private) - If
Thas private fields, the completeness check will report E0470 for each — this is correct behavior: you cannot generically construct a type with private fields from outside its module - Types with private fields must implement construction traits manually
Default Synthesis for $construct_partial
- Synthesize
Default.default()calls for missing fields- For each missing field, resolve its concrete type from
struct_def.field_type(field_name), then emit aDefault.default()call with return type = field type - Uses the same trait dispatch mechanism as generic function monomorphization
- The
Defaultbound is checked at monomorphization time whenis_partial = true. If any field type lacks aDefaultimpl, emit E0473. For generic types, require explicitwhere T: Defaultconstraint to use$construct_partial<T> - Follow Zig’s pattern (
Sema.zig:5030-5083): fetch default value per field, error if no default exists
- For each missing field, resolve its concrete type from
Type Checking Integration
-
Add
infer_constructfunction incompiler/ori_types/src/infer/expr/constructors.rs(or a newconstruct.rsmodule)- This is the function called from the
ExprKind::Constructarm ininfer_expr_inner()(20A.1) - Pre-expansion behavior: resolve
type_paramto a concrete type via the type registry, infer theargsexpression, return the resolved struct type as the expression’s type - Post-expansion (after Section 20.3 infrastructure exists): delegate to
expand_construct()(20A.2), then re-infer the replacementExprKind::Struct/ExprKind::Callnode - The exact split between 20A.1’s type inference arm and 20A.2’s expansion depends on Section 20.3’s expansion architecture — if expansion runs as a separate sub-phase before type checking,
infer_constructmay just call the expansion and re-dispatch; if expansion is interleaved with type checking, it may do both
- This is the function called from the
-
Type-check expanded struct literal through existing pipeline
- After expansion produces
ExprKind::Struct, the normalinfer_struct()runs - This provides redundant completeness checking (belt-and-suspenders)
- Field type unification happens in
infer_struct()atori_types/src/infer/expr/structs/mod.rs:110 - No changes needed to
infer_struct()— it receives a well-formed struct literal
- After expansion produces
Tests
TDD: Write failing test matrix BEFORE implementation. Verify tests fail first, then implement, then verify tests pass unchanged.
-
Write failing expansion unit tests in
compiler/ori_types/src/infer/expr/calls/monomorphization/tests.rs(or newexpansion/tests.rsif expansion is extracted to a separate module) — FIRST, before any implementation:Type x pattern matrix:
Type $construct (full) $construct_partial (with guard) $construct_partial (all defaults) Multi-field struct ( User { name, age, email })fields_of + yield yield with guard (skip email) []empty pairsSingle-field struct ( Wrapper { value: int })single pair n/a (single field) []Newtype ( UserId = int)produces ExprKind::Calln/a (newtypes have no Default) n/a Generic struct ( Pair<A, B>)monomorphized to Pair<int, str>yield with guard []Struct with OptionfieldSome/None values skip Optional field (filled with Nonedefault)all None defaults Struct with nested struct field nested $constructpartial with nested default []Edge cases:
- Empty struct (zero fields):
$construct<Empty>([])producesEmpty {}— boundary condition - Struct with all
Optionfields:$construct_partialwith empty pairs fills all withNone - Single-field struct vs newtype: expansion must distinguish
Wrapper { value: x }fromUserId(x)
Error dimension (each error code gets its own test):
- E0470: missing field —
$construct<User>with onlynamepair, missingageandemail - E0471: duplicate field — two pairs both named
"name" - E0472: extra field — pair with
"nonexistent"field name - E0473: no Default for partial —
$construct_partial<NoDefault>whereNoDefaultlacksDefaultimpl - Private fields from outside module —
$construct<HasPrivate>whereHasPrivatehas private field
- Empty struct (zero fields):
-
Verify failing tests fail — run
timeout 150 cargo t -p ori_typesand confirm all new tests fail before implementing -
Spec tests in
tests/spec/reflection/construct/—.orifiles:construct_basic.ori— multi-field struct withassert_eqon each field valueconstruct_newtype.ori— newtype expansion produces constructor call; verifyUserId(42).inner == 42construct_generic.ori— generic T monomorphized toPair<int, str>andPair<bool, float>construct_partial.ori— partial with defaults; verify default-filled fields have correct valuesconstruct_partial_all_defaults.ori—$construct_partial<T>([])fills every field withDefault.default()construct_errors.ori—#compile_failtests for E0470, E0471, E0472, E0473 (one test per error code)construct_single_field.ori— single-field struct (non-newtype) boundary case
-
AOT tests in
compiler/ori_llvm/tests/aot/construct.rs— compile and run construct scenarios through LLVM pipeline; verify output matches interpreter -
Semantic pin:
$construct<User>($for field in fields_of(User) yield (field, default_value(field)))producesUser { name: "", age: 0, email: None }— only passes with construct expansion producing correct struct literal; would fail if expansion were removed or broken -
Semantic pin (partial):
$construct_partial<Config>([])producesConfig { timeout: 30, retries: 3 }(all defaults) — only passes with Default synthesis; would fail if Default filling were removed -
Semantic pin (newtype):
$construct<UserId>([(inner_meta, 42)])producesUserId(42)— only passes if newtype path emitsExprKind::Call(notExprKind::Struct) -
Verify all tests pass in debug and release —
timeout 150 cargo t -p ori_typesandtimeout 150 cargo b --release -
/tpr-reviewpassed — independent review found no critical or major issues (or all findings triaged) -
/impl-hygiene-reviewpassed — hygiene review clean. MUST run AFTER/tpr-reviewis clean. -
Subsection close-out (20A.2) — MANDATORY before starting the next subsection. Run
/improve-toolingretrospectively on THIS subsection’s debugging journey (per.claude/skills/improve-tooling/SKILL.md“Per-Subsection Workflow”): whichdiagnostics/scripts you ran, where you addeddbg!/tracingcalls, where output was hard to interpret, where test failures gave unhelpful messages, where you ran the same command sequence repeatedly. Forward-look: what tool/log/diagnostic would shorten the next regression in this code path by 10 minutes? Implement improvements NOW (zero deferral) and commit each via SEPARATE/commit-pushusing a valid conventional-commit type (build(diagnostics): ... — surfaced by section-20A.2 retrospective—build/test/chore/ci/docsare valid;tools(...)is rejected by the lefthook commit-msg hook). Mandatory even when nothing felt painful. If genuinely no gaps, document briefly: “Retrospective 20A.2: no tooling gaps”. Update this subsection’sstatusin section frontmatter tocomplete. -
/sync-claudesection-close doc sync — verify Claude artifacts across all section commits. Map changed crates to rules files, check CLAUDE.md, canon.md. Fix drift NOW. -
Repo hygiene check — run
diagnostics/repo-hygiene.sh --checkand clean any detected temp files.
20A.3 Integration, Error Messages, and Verification (verified 2026-03-29 — 21/21 items not-started, error code range E0470-E0473 confirmed unused and safe)
File(s): compiler/ori_diagnostic/src/error_code/mod.rs, tests/spec/reflection/construct/, compiler/ori_eval/src/, compiler/ori_llvm/src/
Goal: Error messages are clear and actionable. End-to-end verification confirms construct produces identical output to hand-written struct literals in both eval and LLVM paths.
Depends on: 20A.1 (parser), 20A.2 (monomorphization expansion).
Error Code Registration
-
Register error codes E0470-E0473 (
compiler/ori_diagnostic/src/error_code/mod.rs)Note on error code range: E0470-E0473 are in the E0xxx range which is nominally the “Lexer errors” range per the
define_error_codes!comment inerror_code/mod.rs(line 29). These are actually monomorphization/expansion errors. This follows Section 20’s convention (E0460-E0464 for reflection errors). Both sections use the E0xxx range because they represent compile-time intrinsic errors that occur before type checking proper. The E0xxx range currently only uses E0001-E0015, E0911, E0932 — so E0460-E0473 are safely in unused territory with no collision risk (verified 2026-03-29). If the error code naming convention is revised to give compile-time intrinsics their own range, both Section 20 and 20A codes should move together. ACTION (from verification finding 1): When implementing, update thedefine_error_codes!comment at line 29 to reflect that E04xx now includes compile-time intrinsic errors, not just lexer errors.Code Message Context E0470 $construct<T> is missing field 'name'Completeness check failed — field in struct but not in pairs E0471 $construct<T> has duplicate field 'name'Same $FieldMeta appears twice in pair list E0472 field 'name' does not exist on type T$FieldMeta doesn’t match any field in T E0473 $construct_partial requires T: DefaultPartial construction used but T lacks Default impl -
Error message factories — follow existing pattern in
ori_types/src/type_error/check_error/mod.rsmissing_field_in_construct(span, type_name, missing_fields)— list missing fields with typesduplicate_field_in_construct(span, field_name)— point to first and second occurrencefield_not_in_type(span, field_name, type_name)— list actual fields of Tconstruct_partial_no_default(span, type_name)— suggest adding: Defaultor using$construct
-
Create error documentation in
compiler/ori_diagnostic/src/errors/E0470.md,E0471.md,E0472.md,E0473.mdwith examples and fixes
Canonicalization Handling
- Verify
unreachable!()arm forConstructin canonicalization dispatch (added in 20A.1 as part of the 11-file match arm task)compiler/ori_canon/src/lower/expr.rshas an exhaustive match onExprKind(line 32)- The arm
ExprKind::Construct { .. } => unreachable!("$construct must be expanded before canonicalization")was added in 20A.1 - Verify this defense-in-depth guard is in place — if expansion fails to run, canonicalization must panic rather than silently miscompile
Evaluator Handling
- Verify no eval changes needed
- After expansion and canonicalization,
ExprKind::Constructhas been rewritten toExprKind::Struct(structs) orExprKind::Call(newtypes), which becomeCanExpr::StructorCanExpr::Callrespectively - The evaluator dispatches on
CanExpr(notExprKind) and already handles bothCanExpr::Structviaeval_can_struct()andCanExpr::Callvia existing call dispatch - No new
CanExprvariant is needed — the evaluator never seesConstruct
- After expansion and canonicalization,
LLVM Codegen Handling
- Verify no LLVM changes needed
- After expansion and canonicalization, LLVM codegen sees
CanExpr::Struct(routed through ARC IR asArcInstr::Construct { CtorKind::Struct(name), args }, then tobuild_struct()atori_llvm/src/codegen/ir_builder/aggregates.rs:179) orCanExpr::Call(for newtypes, routed through normal call codegen) - No new
CanExprvariant is added, so no LLVM changes needed
- After expansion and canonicalization, LLVM codegen sees
ARC Pipeline
- Verify no ARC changes needed
CanExpr::Structis already lowered toArcInstr::Construct { CtorKind::Struct(name), args }viaori_arc/src/lower/collections/mod.rs:55-69;CanExpr::Callflows through normal call lowering- No new
CanExprvariant is added, so no ARC changes needed
End-to-End Verification
TDD: Write flagship test files BEFORE verifying downstream phases. These tests exercise the full pipeline (parse -> type check -> expand -> canonicalize -> eval/LLVM) and serve as the integration test matrix.
All flagship tests go in tests/spec/reflection/construct/.
Integration test matrix (type x construction pattern):
| Type | $construct (full) | $construct_partial | Newtype path |
|---|---|---|---|
User { name: str, age: int } | from_json.ori | partial_defaults.ori | n/a |
Config: Default { timeout, retries, verbose } | n/a | partial_defaults.ori | n/a |
UserId = int (newtype) | newtype.ori | n/a | newtype.ori |
Generic T monomorphized to multiple types | from_json.ori (via pub def impl) | n/a | n/a |
Empty struct Unit = {} | construct_empty.ori | n/a | n/a |
-
Flagship test: generic FromJson (
tests/spec/reflection/construct/from_json.ori) [SEMANTIC PIN — only passes if$construct<Self>correctly expands per-type via monomorphization]type User = { name: str, age: int } trait FromJson { @from_json (json: JsonValue) -> Result<Self, Error> } pub def impl FromJson { @from_json (json: JsonValue) -> Result<Self, Error> = { let obj = json.as_object()? $construct<Self>( $for field in fields_of(Self) yield { (field, FromJson.from_json(json: obj[field.name])?) } ) } }Verify this expands to
User { name: ..., age: ... }for User. Useassert_eqon field values. -
Flagship test: partial construction with defaults (
tests/spec/reflection/construct/partial_defaults.ori) [SEMANTIC PIN — only passes if Default synthesis fills missing fields]type Config: Default = { timeout: int, retries: int, verbose: bool } @from_env () -> Config = { $construct_partial<Config>( $for field in fields_of(Config) if has_env(field.name) yield { (field, parse_env(field.name)) } ) } -
Flagship test: newtype construction (
tests/spec/reflection/construct/newtype.ori) [SEMANTIC PIN — only passes if newtype path emitsExprKind::Call, notExprKind::Struct]type UserId = int @from_json_id (json: JsonValue) -> Result<UserId, Error> = { $construct<UserId>( $for field in fields_of(UserId) yield { (field, json.as_int()?) } ) }Verify this expands to
UserId(json.as_int()?)— a constructor call, not a struct literal. -
Flagship test: empty struct (
tests/spec/reflection/construct/construct_empty.ori) — boundary case:$construct<Unit>([])fortype Unit = {}producesUnit {} -
Error message verification — run each
#compile_failtest intests/spec/reflection/construct/construct_errors.oriand verify error messages contain:- E0470: the missing field name(s) and the type name
- E0471: the duplicate field name and span of first occurrence
- E0472: the invalid field name and list of valid fields
- E0473: the type name and suggestion to add
: Default
-
Zero-overhead verification — LLVM IR for
$construct<User>(...)must be identical toUser { name: x, age: y }- Use
diagnostics/ir-diff.shto compare hand-written vs $construct versions - Also verify
$construct<UserId>(...)produces identical IR toUserId(value) - No extra instructions, no intermediate allocations
- Use
-
Eval-vs-LLVM equivalence — run reflection construct tests through both paths
- Use
diagnostics/dual-exec-verify.sh tests/spec/reflection/construct/
- Use
-
AOT end-to-end — compile and run flagship tests via
ori build+ execute native binary; verify output matches interpreter -
Verify all tests pass in debug and release —
timeout 150 ./test-all.sh(full suite) andtimeout 150 cargo b --release
Spec and Documentation
-
Update spec Clause 27 — add $construct/$construct_partial to the compile-time reflection clause
- Run
/sync-specfor formal language
- Run
-
Update
grammar.ebnf— add$constructand$construct_partialproductions- Run
/sync-grammar
- Run
-
Verify
.claude/rules/ori-syntax.md— add $construct to Compile-Time Reflection section -
/tpr-reviewpassed — independent review found no critical or major issues (or all findings triaged) -
/impl-hygiene-reviewpassed — hygiene review clean. MUST run AFTER/tpr-reviewis clean. -
Subsection close-out (20A.3) — MANDATORY before starting the next subsection. Run
/improve-toolingretrospectively on THIS subsection’s debugging journey (per.claude/skills/improve-tooling/SKILL.md“Per-Subsection Workflow”): whichdiagnostics/scripts you ran, where you addeddbg!/tracingcalls, where output was hard to interpret, where test failures gave unhelpful messages, where you ran the same command sequence repeatedly. Forward-look: what tool/log/diagnostic would shorten the next regression in this code path by 10 minutes? Implement improvements NOW (zero deferral) and commit each via SEPARATE/commit-pushusing a valid conventional-commit type (build(diagnostics): ... — surfaced by section-20A.3 retrospective—build/test/chore/ci/docsare valid;tools(...)is rejected by the lefthook commit-msg hook). Mandatory even when nothing felt painful. If genuinely no gaps, document briefly: “Retrospective 20A.3: no tooling gaps”. Update this subsection’sstatusin section frontmatter tocomplete. -
/sync-claudesection-close doc sync — verify Claude artifacts across all section commits. Map changed crates to rules files, check CLAUDE.md, canon.md. Fix drift NOW. -
Repo hygiene check — run
diagnostics/repo-hygiene.sh --checkand clean any detected temp files.
20A.R Third Party Review Findings
- None. (verified 2026-03-29 — correct, no implementation exists to review)
Verification Findings (2026-03-29)
Seven minor findings from independent verification. No blockers; all informational or minor quality items to address during implementation:
-
Error code range comment (NOTE) — E0470-E0473 are in the E0xxx range documented as “Lexer errors” in
error_code/mod.rsline 29. When implementing, update thedefine_error_codes!comment to reflect expanded range usage for compile-time intrinsic errors (follows Section 20’s E0460-E0464 convention). -
Missing plan annotation cleanup section (DRIFT) — Per CLAUDE.md: “Every plan MUST include a final cleanup section to strip all its code annotations.” The 20A.4 Completion Checklist does not include a cleanup step to remove
20A.xcode annotations. Added below. -
Missing
ori_canon/src/desugar/calls.rsin non-exhaustive list (NOTE) —compiler/ori_canon/src/desugar/calls.rshas ExprKind references with a wildcard match (_ =>at line 141). Not mentioned in the “Non-exhaustive matches (no changes needed)” list. No change needed (wildcard handles it), but noted for completeness. -
Missing interaction testing spec (WEAK TESTS) — Test matrices focus on type x construction-pattern dimensions but do not explicitly plan interaction tests with closures, error handling (
?), pattern matching, or trait default impl bodies. The FromJson flagship covers some interactions, but explicit interaction test items should be added during implementation. -
Newtype
$construct_partialbehavior under-specified (GAP) — The test matrix marks newtype +$construct_partialas “n/a” without specifying the behavior of$construct_partial<UserId>([]). Should clarify: either error (no Default for newtypes) or fill withDefault.default()for the inner type. The expansion logic suggests Default-fill would work if the inner type has Default, but the plan’s test matrix omits coverage. -
alloc_expr_rangenon-existence note accurate (NOTE) — The plan correctly notes thatalloc_expr_rangedoes not exist andalloc_expr_list_inlinemust be used instead. Verified accurate. -
Section 20.3 arena access uncertainty properly flagged (NOTE) — The plan correctly identifies the load-bearing dependency on Section 20.3’s arena allocation mechanism and offers two approaches. This is proper dependency documentation, not a gap.
Codebase Hygiene Findings (discovered during plan review)
These files are touched by this section and have existing hygiene issues. Fix along the way when adding Construct match arms:
| File | Lines | Issue | Action |
|---|---|---|---|
ori_fmt/src/formatter/inline.rs | 520 | At BLOAT limit (500 excl. tests) | Adding arm pushes over — evaluate if any stale/duplicated arm logic can be extracted |
ori_fmt/src/formatter/broken.rs | 511 | At BLOAT limit | Same as inline.rs |
ori_fmt/src/width/mod.rs | 579 | BLOAT (79 lines over) | Extract width helpers for complex variants to width/helpers.rs when adding Construct |
oric/src/ir_dump/expr.rs | 617 | BLOAT (117 lines over) | Extract leaf-node dump to ir_dump/expr_leaves.rs |
oric/src/ast_dump/expr.rs | 587 | BLOAT (87 lines over) | Extract leaf-node dump to ast_dump/expr_leaves.rs |
ori_parse/src/incremental/copier.rs | 1595 | Severe BLOAT (3x limit) | Not in scope for 20A, but note for future extraction |
ori_ir/src/ast/expr.rs | 510 | Barely over limit | Adding Construct variant is +3 enum lines and +1 Debug line — minor |
These are not blocking issues for 20A, but implementers should extract where practical while touching these files to avoid worsening the BLOAT.
20A.4 Completion Checklist (verified 2026-03-29 — all 21 items confirmed not-started, correct for section status)
Functional correctness:
-
$construct<User>($for field in fields_of(User) yield (field, value))produces correctUserstruct -
$construct_partial<Config>([])producesConfigwith all default values - Generic
Tworks —$construct<T>in generic function monomorphized to multiple types - Newtype works —
$construct<UserId>(...)expands toUserId(value)viaExprKind::Call, notExprKind::Struct - Empty struct works —
$construct<Unit>([])producesUnit {}
Error diagnostics:
-
$construct<T>with missing field emits E0470 with field name and type -
$construct<T>with duplicate field emits E0471 with both locations -
$construct<T>with extra field emits E0472 with valid field list -
$construct_partial<T>without Default emits E0473 with suggestion - E0470-E0473 documentation files created in
compiler/ori_diagnostic/src/errors/
Codegen and equivalence:
- LLVM IR identical to hand-written struct literal (
ir-diff.shzero differences) - Eval and LLVM paths produce identical results (
dual-exec-verify.sh) - AOT end-to-end: flagship tests compile and run as native binaries with correct output
Structural integrity:
- No
ExprKind::Constructnodes survive past monomorphization (unreachable!()inori_canon/src/lower/expr.rsdoes not fire) - All 11 exhaustive
ExprKindmatch sites have arms forConstruct -
static_assert_size!(ExprKind, 24)still passes (variant fits within size budget)
Testing completeness:
- Parse unit tests cover type x pattern matrix (20A.1)
- Expansion unit tests cover type x pattern x error matrix (20A.2)
- AOT tests in
compiler/ori_llvm/tests/aot/construct.rs(20A.2) - Spec tests in
tests/spec/reflection/construct/cover all flagship scenarios (20A.3) - Semantic pin tests exist for: full construct, partial construct, newtype construct
- All tests pass in both debug and release builds
Suite and documentation:
-
./test-all.shgreen -
./clippy-all.shgreen - Spec Clause 27 updated with $construct/$construct_partial
-
grammar.ebnfupdated with $construct productions -
.claude/rules/ori-syntax.mdupdated with $construct in Compile-Time Reflection section -
/tpr-reviewpassed — independent Codex review found no critical or major issues (or all findings triaged) -
/impl-hygiene-reviewpassed — implementation hygiene review clean (phase boundaries, SSOT, algorithmic DRY, naming). MUST run AFTER/tpr-reviewis clean. -
/improve-toolingretrospective completed — MANDATORY at section close, after both reviews are clean. Reflect on the section’s debugging journey (whichdiagnostics/scripts you ran, which command sequences you repeated, where you added ad-hocdbg!/tracingcalls, where output was hard to interpret) and identify any tool/log/diagnostic improvement that would have made this section materially easier OR that would help the next section touching this area. Implement every accepted improvement NOW (zero deferral) and commit each via SEPARATE/commit-push. The retrospective is mandatory even when nothing felt painful — that is exactly when blind spots accumulate. See.claude/skills/improve-tooling/SKILL.md“Retrospective Mode” for the full protocol.
Cleanup:
- Remove all
20A.xcode annotations from source files (per CLAUDE.md: plan annotations are temporary scaffolding)
Exit Criteria: $construct<User>($for field in fields_of(User) yield (field, parse(field.name))) compiles to identical LLVM IR as User { name: parse("name"), age: parse("age") }. $construct<UserId>(...) expands to UserId(value) via constructor call (not struct literal). All 4 error codes (E0470-E0473) produce clear diagnostics. Generic, newtype, partial, and empty-struct construction all work. Eval and LLVM paths match. All tests pass in debug and release. ./test-all.sh and ./clippy-all.sh green.
Inspired By
- Zig
zirStructInit()completeness + default field mechanism (src/Sema.zig:19438-19750) - C++26 P2996
template for+ splice struct construction