Files
sx/issues/0075-reflection-builtin-s64-type-fallback.md
agra 633c0a2540 docs(issues): file 0075 — silent .s64 type fallback in reflection builtins
Discovered during the 0074 fix + a codebase-wide silent-type-fallback sweep.
getRefIRType(...) orelse TypeId.s64 at ops.zig:1023/1049/1055 (type_name/type_eq).
Blocker; to be resolved before the arch-refactor stream closes.
2026-06-03 15:55:32 +03:00

5.0 KiB

0075 — silent getRefIRType(...) orelse TypeId.s64 fallback in reflection builtins

Symptom

One-line: The type_name and type_eq reflection builtins resolve their Type argument's IR type via getRefIRType(...) orelse TypeId.s64 — the forbidden silent-type-lookup fallback (.s64 is the exact issue-0042 sentinel the project rules name) — so a failed must-succeed lookup silently decides "not boxed (!= .any)" and mis-handles the value with no diagnostic.

Observed (primary — must fix): self.e.getRefIRType(...) orelse TypeId.s64 at:

  • src/backend/llvm/ops.zig:1023 (.type_name builtin — arg_ir_ty, gates the == .any boxed-extract vs bare-i64 decision)
  • src/backend/llvm/ops.zig:1049 (.type_eq builtin — first operand)
  • src/backend/llvm/ops.zig:1055 (.type_eq builtin — second operand)

getRefIRType (src/ir/emit_llvm.zig:2229, ?TypeId) returns null only when a ref is neither a function param nor a block instruction result — a must-not-happen case for a real builtin argument. On null the code defaults to .s64, then tests arg_ir_ty == .any; the .s64 default silently means "treat as a bare TypeId index, not a boxed Any", so a genuinely-boxed arg whose lookup failed would skip the ExtractValue and use the wrong value — silent miscompile, no diagnostic.

Expected: per CLAUDE.md REJECTED PATTERNS, a failed must-succeed type lookup surfaces a diagnostic / hard tripwire (e.g. the .unresolved sentinel introduced for issue 0074), never a real-type default.

Secondary (confirm — borderline)

  • src/ir/lower.zig:2527.null_literal => constNull(self.target_type orelse .void)
  • src/ir/lower.zig:2528.undef_literal => constUndef(self.target_type orelse .void) target_type is a context hint that may be legitimately absent for a bare null/undef with no expected type — this may be an INTENTIONAL default rather than a lookup-swallow. The fix session should confirm: if a null/undef literal reaching here without a target_type is actually a must-not-happen case, make it loud; if a typeless null/undef is legitimate, leave it and add a one-line comment stating the invariant.

Audited — intentional language defaults (NO action; documented so they aren't re-flagged)

  • src/ir/lower.zig:4855int_literal => constInt(lit.value, info.ty orelse .s64): an untyped integer literal defaulting to s64 is standard language semantics, not a lookup failure.
  • src/ir/lower.zig:4856float_literal => constFloat(..., info.ty orelse .f64): untyped float literal defaults to f64 — language semantics.
  • src/ir/type_bridge.zig:334.tag_type = tag_type orelse .s64: documented ("enum unions are always tagged (default i64)") — an intentional default tag type, not a swallowed lookup.

Provenance / scope

Pre-existing, NOT introduced by the arch-refactor. Discovered during the issue-0074 fix (the fix worker surfaced the reflection .s64 fallbacks as a separate pattern outside 0074's FFI-arg scope) and confirmed by a manager sweep (rg "orelse \.(s64|void|...)" src). Filed per the IMPASSIBLE RULE (existing default-returns that swallow a lookup failure → file, don't fix in place).

Reproduction

Latent / static (same nature as 0074): well-formed IR always gives a builtin arg a resolvable type, so the .s64 default can't be driven at runtime today — which is why it's dangerous (a future IR change would silently miscompile type_name/type_eq). Exercised by the comptime/reflection examples; the fix must keep the suite at 361/0.

Investigation prompt (ready to paste into a fresh session)

In /Users/agra/projects/sx, the .type_name and .type_eq reflection builtins in src/backend/llvm/ops.zig (lines 1023, 1049, 1055) resolve a Type argument's IR type with the forbidden silent fallback getRefIRType(...) orelse TypeId.s64, used to gate a == .any boxed-vs-bare decision. Issue 0074 already added the shared resolver LLVMEmitter.argIRTypeOrFail (src/ir/emit_llvm.zig) returning the dedicated .unresolved sentinel on a failed lookup. Route these three sites through that helper (or a sibling) so a failed lookup yields .unresolved — never .s64; then ==.any is false for .unresolved AND you must make the unresolved case loud (diagnostic via self.diagnostics.addFmt(.err, span, ...) or a hard tripwire), not silently "bare i64". Also resolve the borderline lower.zig:2527/2528 target_type orelse .void (confirm intentional vs make-loud; comment the invariant either way). Leave the audited intentional defaults (lower.zig:4855/4856, type_bridge.zig:334) untouched. Verify: /Users/agra/.zvm/bin/zig build && /Users/agra/.zvm/bin/zig build test && bash tests/run_examples.sh stays 361/0; add a *.test.zig regression test asserting the loud .unresolved path for a type_name/type_eq arg with an unresolvable ref (fail-before/pass-after). Expected new behavior: an unresolved reflection-builtin arg type surfaces loudly, never silently defaults to .s64.