Quartz v5.25

Bug 2: “Enum variant with named payload, match extracts” — returns 0

Spec file: spec/qspec/stress_type_system_spec.qz Failing test: "enum variant with named payload, match extracts" (line 527) Observed: returns 0 instead of expected 42. Verdict: the test as written has a typo in the pattern names (MyOk / MyErr instead of MyResult::Ok / MyResult::Err), but the compiler is silently accepting the typo and miscompiling it to a vacuous match. Named-payload extraction itself works — multi-field Message::Text(content, n) extracts correctly. The real bug is that the compiler accepts unknown variant names in patterns without diagnosing them.

1. Problem (as observed)

Test source (verbatim, stress_type_system_spec.qz:527-542):

it("enum variant with named payload, match extracts") do ->
  assert_run_exits("""
    enum MyResult
      Ok(value: Int)
      Err(code: Int)
    end
    def main(): Int
      var r = MyResult::Ok(42)
      match r
        MyOk(v) => return v     # typo: should be MyResult::Ok(v)
        MyErr(c) => return c    # typo: should be MyResult::Err(c)
      end
      return 0
    end
  """, 42)
end

The test author presumably intended MyResult::Ok(v) / MyResult::Err(c) but wrote MyOk(v) / MyErr(c). The compiler accepts it. The generated LLVM IR compares the subject pointer to 0 (which the typo resolves to) — never matches — and returns 0 from return 0.

2. Reproduced and diagnosed

2.1 With the typo (as written in the test)

enum MyResult
  Ok(value: Int)
  Err(code: Int)
end
def main(): Int
  var r = MyResult::Ok(42)
  match r
    MyOk(v) => return v
    MyErr(c) => return c
  end
  return 0
end

Exits 0. Generated IR:

%v1 = ptrtoint ptr %alloc_1 to i64    ; pointer to [tag=0, payload=42]
store i64 %v1, ptr %r, align 8
%cmp_tmp7 = icmp eq i64 %v1, %v6      ; <-- %v6 = add i64 0, 0 (variant index of unknown "MyOk")
%v7 = zext i1 %cmp_tmp7 to i64
br i1 %cmp7, label %match_arm2, label %match_next3
...
match_next3:
  %cmp_tmp11 = icmp eq i64 %v1, %v10  ; <-- %v10 = 0 again for unknown "MyErr"
  br i1 %cmp11, label %match_arm4, label %match_next5
match_next5:
  br label %match_end1
match_end1:
  ret i64 %v14                         ; <-- %v14 = add i64 0, 0 (the `return 0` fallthrough)

Every arm compares the subject pointer (a nonzero allocation address) against 0. Both comparisons are false. Control falls through to return 0.

2.2 With the correct pattern names

match r
  MyResult::Ok(v) => return v
  MyResult::Err(c) => return c
end

Exits 42. Named payload extraction works correctly.

2.3 Multi-field named payload

enum Message
  Text(s: String, len: Int)
  Ping
end
def main(): Int
  var m = Message::Text("hello", 42)
  match m
    Message::Text(content, n) => return n
    _ => return -1
  end
  return 0
end

Exits 42. Multi-field extraction by position works correctly.

So the failure is not about named payloads. It’s about pattern names that don’t correspond to any known enum variant silently lowering to nonsense.

3. Current Quartz state

3.1 Parser

self-hosted/frontend/parser.qz:4671-4779ps_parse_pattern. When it sees IDENT(...) (ident followed by parens), it creates an unqualified enum access pattern:

# Unqualified enum pattern: Variant(args) without Type:: prefix
if ps_check(ps, token_constants::TOK_LPAREN)
  ...
  return ast::ast_enum_access(s, "", name, uq_bound_names, 0, ln, cl)
end

So MyOk(v) parses as NODE_ENUM_ACCESS { enum_name: "", variant: "MyOk", bound_names: [v] }. The parser does NOT validate that MyOk is a known variant — that’s the typechecker’s job.

3.2 Typechecker

self-hosted/middle/typecheck_expr_handlers.qz:648-886tc_expr_match. For enum access patterns it calls tc_bind_pattern_variables:

if pattern_kind == node_constants::NODE_ENUM_ACCESS
  var bound_names = ast::ast_get_extra(ast_storage, pattern)
  var pat_enum_name = ast::ast_get_str1(ast_storage, pattern)
  var pat_variant_name = ast::ast_get_str2(ast_storage, pattern)
  ...
  typecheck_match::tc_bind_pattern_variables(tc, bound_names, subject_type, pat_enum_name, pat_variant_name, line, col)

self-hosted/middle/typecheck_match.qz:101-231tc_bind_pattern_variables. When enum_name is empty, it scans all registered enums looking for one containing variant_name:

if str_byte_len(enum_name) == 0 and str_byte_len(variant_name) > 0
  var all_names = typecheck_registry::tc_all_enum_names(tc)
  var ne_count = all_names.size
  for ei in 0..ne_count
    var cand_name = all_names[ei]
    ...
    if typecheck_registry::tc_enum_has_variant(tc, cand_idx, variant_name) == 1
      enum_name = cand_name
      break
    end
  end
end

For MyOk, no enum has that variant. enum_name stays empty. The function falls through to the fallback path (line 207-219): binds all pattern variables to TYPE_INT. No error is raised.

3.3 Exhaustiveness check

self-hosted/middle/typecheck_match.qz:364-531tc_check_exhaustiveness. It tries to determine the subject’s enum name:

if subject_type == type_constants::TYPE_ENUM
  # Strategy 1: from subject ident via scope lookup
  ...
  # Strategy 2: from first enum access pattern in arms
  if str_byte_len(enum_name) == 0
    for i in 0..arm_count
      ...
      if ast::ast_get_kind(ast_storage, pattern) == node_constants::NODE_ENUM_ACCESS
        var candidate = ast::ast_get_str1(ast_storage, pattern)  # = "" for unqualified
        if str_byte_len(candidate) > 0
          enum_name = candidate
        else
          # Unqualified pattern — find enum by variant name
          var vname = ast::ast_get_str2(ast_storage, pattern)  # = "MyOk"
          ...
          if typecheck_registry::tc_enum_has_variant(tc, eidx, vname)
            enum_name = all_names[ei]
          end
        end
      end
    end
  end

When NO arm’s variant name matches any registered enum (the typo case), enum_name stays empty, the outer if str_byte_len(enum_name) > 0 block is skipped, and no “non-exhaustive match” error is raised either.

Additionally, subject_type for var r = MyResult::Ok(42) may not be TYPE_ENUM at all — it may be a ptype (parametric enum type) or even TYPE_INT if the typechecker fails to unify it. In that case the if subject_type == type_constants::TYPE_ENUM branch is entirely bypassed, and exhaustiveness checking is skipped for the whole match.

3.4 MIR lowering

self-hosted/backend/mir_lower_expr_handlers.qz:2946-2975 — the enum pattern path:

if pattern_kind == node_constants::NODE_ENUM_ACCESS
  var base_enum_name = ast::ast_get_str1(s, pattern_node)  # = ""
  var variant_name = ast::ast_get_str2(s, pattern_node)    # = "MyOk"
  var enum_name = ""
  if str_byte_len(base_enum_name) == 0
    enum_name = mir_find_enum_by_variant(ctx, variant_name)  # returns "" (not found)
  else
    enum_name = mir_resolve_enum_name(ctx, base_enum_name)
  end
  var tag_val = mir_get_enum_tag(ctx, enum_name, subject_val)
  var variant_idx = mir_find_variant_index_ctx(ctx, enum_name, variant_name)
  var idx_val = ctx.mir_emit_const_int(variant_idx)
  cmp_val = ctx.mir_emit_binary(op_constants::OP_EQ, tag_val, idx_val)

With enum_name = "":

  • mir_get_enum_tag(ctx, "", subject_val)mir_enum_has_payloads(ctx, "") returns 0 (empty name not found) → function returns subject_val unchanged. So tag_val = the subject pointer, not the tag!
  • mir_find_variant_index_ctx(ctx, "", "MyOk") → no built-in match, falls through, returns 0.
  • The comparison becomes subject_pointer == 0 — always false.

Both mir_get_enum_tag and mir_find_variant_index_ctx silently produce nonsense when the enum name is empty. Nowhere in MIR lowering does anything check “hey, we couldn’t resolve this variant — refuse to compile.” The MIR lowering path trusts that the typechecker has already validated the pattern, but the typechecker has no such check.

3.5 Relevant source locations (summary)

  • Parser: self-hosted/frontend/parser.qz:4741-4776 — emits unqualified NODE_ENUM_ACCESS with empty base name.
  • TC pattern binding: self-hosted/middle/typecheck_match.qz:111-204 — silently falls through when variant is unknown.
  • TC exhaustiveness: self-hosted/middle/typecheck_match.qz:418-451 — skips check when enum name cannot be derived.
  • TC match dispatcher: self-hosted/middle/typecheck_expr_handlers.qz:717-729 — calls tc_bind_pattern_variables without checking its outcome.
  • MIR resolver: self-hosted/backend/mir.qz:3322-3423mir_get_enum_tag and mir_find_enum_by_variant both silently return empty/default on unknown names.
  • MIR lowering: self-hosted/backend/mir_lower_expr_handlers.qz:2946-2975 — trusts the resolver; emits subject == 0 when resolution fails.

4. External research

4.1 How Rust handles unknown variant names in patterns

From Rust Compiler Error Index — error E0531:

An unresolved path was used in a pattern. … The pattern Some(x) or None must be resolved to a variant of Option. If the pattern cannot be resolved, the compiler emits E0531.

Rust example:

enum MyResult { Ok(i32), Err(i32) }
fn main() {
    let r = MyResult::Ok(42);
    match r {
        MyOk(v) => println!("{}", v),  // error[E0531]: cannot find tuple struct or tuple variant `MyOk` in this scope
        MyErr(c) => println!("{}", c),
    }
}

The error fires at the resolver stage, before typechecking. The pattern is eagerly resolved against the subject’s type. See Rust issue #14221 and Rust issue #103442 for historical discussion of the fine-grained behavior.

Key principle: Rust refuses to compile a match where any arm’s pattern cannot be resolved to a known variant, constant, or binding, in the subject’s type.

4.2 How Swift handles it

From Pattern Matching, Part 1 (Alisoftware) and SE-0155:

Swift enum patterns use .case syntax. case .text(let content, let n): refers to .text on the type of the switched-on value. If you write case .mytypo(let v): on a Message subject, Swift emits:

error: type ‘Message’ has no member ‘mytypo’

Same policy as Rust: unresolvable case = hard error.

4.3 How named payloads are actually extracted

All three languages that support named-field enum variants (Rust struct-variants, Swift named associated values, OCaml record variants) store the fields positionally at runtime and rely on the type system to map names to positions at compile time. Quartz does the same: the MIR-level extraction is load_offset(subject, field_idx) where field_idx comes from the enum’s field-declaration order.

Evidence that Quartz’s positional extraction is correct: the Message::Text(content, n) reproducer in §2.3 works. The bug is specifically in the resolution of unknown names, not in extraction.

4.4 The canonical design

The policy that Rust/Swift/OCaml/Haskell all converge on:

  1. Parser emits a generic “constructor pattern” node with a name and a sub-pattern list.
  2. Resolver/typechecker resolves the constructor name against the subject type’s variants. If there’s no match, emit E0531-style “cannot find variant X in scope” error pointing at the pattern, not a non-exhaustive error after the fact.
  3. Exhaustiveness checker runs on the already-resolved pattern set, treating any arm with an unresolvable name as “no cover” (but the earlier error is what actually stops compilation).
  4. Lowering can assume all patterns are resolved. It never needs to handle “unknown variant.”

Quartz currently does 1 (correct), skips 2 (the bug), has a partial 3 that gets bypassed, and has a lowering (4) that silently produces garbage when it sees an unresolvable pattern.

5. Root cause hypothesis

There is no “unknown variant in pattern” error path in the typechecker. tc_bind_pattern_variables and tc_check_exhaustiveness both treat “variant not found in any registered enum” as a successful no-op, and the MIR lowering path trusts them.

The specific missing check is: after tc_bind_pattern_variables scans all enums for an unqualified variant name, if the enum name is still empty AND the variant name is non-empty, that’s a compile error — the pattern refers to a variant that doesn’t exist.

Additionally, when the subject type IS known (it’s MyResult in this case), the pattern’s variant should be checked specifically against the subject’s variants, not against all registered enums. A pattern Ok(v) in a match on MyResult should resolve to MyResult::Ok, not to Result::Ok, even if both exist.

6. Fix plan

Phase 1: Hard error on unresolvable enum patterns (CORRECT FIX)

File: self-hosted/middle/typecheck_match.qz

Change tc_bind_pattern_variables so that when:

  • enum_name is empty after the all-enums scan, AND
  • variant_name is non-empty,

it emits tc_error(tc, "unknown variant '#{variant_name}' in pattern", line, col) and returns without binding variables.

Estimated lines: ~15.

Change tc_expr_match (typecheck_expr_handlers.qz:717-729) so that when subject_type is known to be a specific enum, it:

  1. Passes the subject’s enum name into tc_bind_pattern_variables instead of letting the callee scan all enums.
  2. Validates that the pattern’s variant (qualified or not) is a variant of the subject’s enum; otherwise emit "variant '#{variant_name}' not found in enum '#{subject_enum_name}'".

Estimated lines: ~30.

Phase 2: Make MIR lowering loud on resolver failure

File: self-hosted/backend/mir_lower_expr_handlers.qz and self-hosted/backend/mir.qz

Add a panic() or internal-error abort in mir_lower_match_expr when mir_find_enum_by_variant returns an empty string for an unqualified pattern. This is a belt-and-braces check — after Phase 1, the typechecker should have already rejected the program. But if a regression slips past the typechecker, we want an unambiguous “compiler bug: unknown variant reached MIR” error rather than silent nonsense code.

Also: mir_get_enum_tag(ctx, "", ...) currently returns subject_val unchanged when the enum name is empty. Change it to panic when given an empty name. Same for mir_find_variant_index_ctx.

Estimated lines: ~20.

Phase 3: Fix the spec file typo

File: spec/qspec/stress_type_system_spec.qz:536-537

Change the test to use correct variant names. Once Phase 1 is in place, the current form would be a hard compile error — keeping it would break the suite. The test becomes:

match r
  MyResult::Ok(v) => return v
  MyResult::Err(c) => return c
end

With Phase 1 fix, this test will exit 42 (already verified manually with the current compiler when patterns use the correct names).

Estimated lines: 2-line test edit.

Phase 4: Scan the rest of the suite for latent typos

Once Phase 1 is in place, run the full QSpec suite and fix any other tests that were silently relying on the old “accept typos” behavior. There are probably a few.

Estimated time: 0.25 day (dependent on how many typos exist).

7. Quartz-time estimate

  • Phase 1: 0.5 day (typechecker change + tests for good error messages)
  • Phase 2: 0.25 day (MIR belt-and-braces)
  • Phase 3: 5 minutes (spec typo fix)
  • Phase 4: 0.25 day (suite sweep)

Total: ~1 day (quartz-time).

8. Risk

  • Low. Phase 1 is a strictly additive check — programs that were already correct remain correct; programs with typos go from “silently wrong exit code” to “clear compile error.”
  • One caveat: there may be real tests in the QSpec suite that depend on the old behavior because they use Ok(x) / Some(x) unqualified patterns and rely on the all-enums scan to pick the right enum. Those tests are already supported by the existing scan (tc_bind_pattern_variables:116-133) and will continue to work — the new error only fires when the scan returns NO match. Verify this by running the spec suite after Phase 1.
  • Medium risk area: the subject-type-driven resolution in Phase 1 step 2. If the subject’s ptype isn’t well-formed (e.g., TYPE_ENUM without a specific enum name attached), the resolver could regress legitimate matches. Mitigation: keep the all-enums fallback as a second-chance resolution, and only error if BOTH fail.

9. Out of scope

  • Named-payload field-by-name extraction in patterns (match m { Text { s, len } => ... }). Not requested by the test. Would be additive: parser already handles struct-style destructuring (NODE_STRUCT_DESTRUCTURE). Positional extraction already works.
  • Exhaustiveness checking for parametric enum ptypes. The existing check only fires for TYPE_ENUM, not for parametric enum instances. This is a real hole but bigger than this bug; it should be a separate roadmap item.

10. Citations

  1. Rust Compiler Error Index — error E0531 unresolved pattern.
  2. Rust issue #14221 — match variant with fields unresolved when enum type in scope
  3. Rust issue #103442 — Matching field-less enum variants that aren’t imported should deny-lint
  4. Rust RFC 2008 — non_exhaustive
  5. Rust Reference — non_exhaustive attribute
  6. Pattern Matching, Part 1: switch, enums & where clauses (Alisoftware)
  7. Swift SE-0155 — Normalize Enum Case Representation
  8. Matching multiple enum cases with associated values (Swift by Sundell)
  9. Enums and Pattern Matching in Rust (Serokell)