Skip to content

Cross-cutting: centralise canonical Vera-type-name resolution to close the #602 bug class structurally #630

@aallan

Description

@aallan

Origin: Eight distinct triggers of the same i32_pair-into-i64 mismatch shape, accumulated across #602, #614, PR #627, and PR #629. Each one was fixed locally; each fix added one more isinstance-handler or one more inference site. The next trigger is a matter of when, not if. Time to fix the shape.

The pattern

Every trigger has the same four-step shape:

  1. Some inference site in vera/wasm/inference.py walks a TypeExpr to extract a canonical Vera type name string ("String", "Array", etc.).
  2. The walk is ad-hoc: each site re-implements its own combination of (a) RefinementType unwrap, (b) alias-chain follow via _resolve_base_type_name, (c) generic substitution, (d) format-with-args.
  3. Some combination is missed at some site → the walk returns a non-canonical string ("Str", an alias name) or None.
  4. Downstream _translate_interpolated_string (vera/wasm/operators.py:455-486) checks vera_type == "String" → mismatch → silent fallthrough to to_string(...) over i32_pairexpected i64, found i32 at WASM validation, OR a call_indirect indirect-call type mismatch at runtime.

The cartesian product is (N inference sites) × (M type-expression shapes). We've patched roughly 7 sites and 5 shapes — both numbers will keep growing as the language grows.

Triggers fixed so far

# PR Trigger shape Site fixed
1 #602 Plain String-returning FnCall in interpolation _infer_fncall_vera_type i32_pair branch (registry consult added)
2 #614 f()[i] where f returns Array<T> _infer_index_element_type_expr FnCall branch
3 PR #627 Type alias over String (type Str = String) Same site, _resolve_base_type_name resolution added
4 PR #629 Inline refinement @{ @String | p } _resolve_i32_pair_ret_te helper added
5 PR #629 Nested refinements @{ @{ @String | p1 } | p2 } while-loop unwrap
6 PR #629 Refinement-Array<T> indexed via FnCall Parallel while-loop in IndexExpr branch
7 PR #629 apply_fn(@FnAlias.0, ()) where FnAlias has nested-refinement return _infer_fncall_vera_type apply_fn + _resolve_generic_fn_return + _fn_type_return_wasm (three sites)
8 PR #629 apply_fn over FnType-aliased return (type Maker = fn(Unit -> Str) where Str = String) _format_named_type_canonical helper added

Five overlapping helpers have accumulated, none of which does the full job:

Helper Unwraps RefinementType Follows aliases Handles type_args
_format_named_type
_format_named_type_canonical (#629)
_resolve_i32_pair_ret_te (#629) ✓ (loop)
_resolve_base_type_name ✓ (single) n/a (string-only)
ad-hoc while loops in apply_fn / FnType helpers mixed mixed

Proposed fix — three tiers

Tier 1 — centralise canonicalisation (highest leverage, ~1–2 hr). Fold the five overlapping helpers into a single _canonical_vera_type(te: ast.TypeExpr, alias_map: dict[str, ast.TypeExpr] | None = None) -> str | None that handles all four concerns (RefinementType unwrap, alias-chain follow, generic substitution, format-with-args). Replace every ad-hoc walk with a call. New triggers in this class become structurally impossible on the producer side.

Sites to migrate (initial list — confirm during refactor):

  • _infer_fncall_vera_type apply_fn branch (vera/wasm/inference.py:638-687)
  • _resolve_i32_pair_ret_te (inference.py:834-884)
  • _resolve_generic_fn_return (inference.py:~1113)
  • _fn_type_return_wasm (inference.py:~1163)
  • _infer_index_element_type_expr FnCall branch (inference.py:937-948)
  • _format_named_type_canonical (inference.py:486+)

Tier 2 — make the silent fallthrough loud (~30 min). The actual silent failure is at vera/wasm/operators.py:482-486 (the else branch in _translate_interpolated_string). Pre-#602 it carried a # pragma: no cover claiming unreachability — disproved 8 times now. Convert it to a hard compile-time error with a clear E-code (something like [E614] cannot interpolate value of type X — inference returned None). Dovetails with #626 (silent-skip pattern) — natural tracking sibling.

This is independently shippable and catches any future canonicaliser gap at compile time, not WASM-validation time. Even with Tier 1 in place, an inference miss on a new type-expression shape would be diagnosed loudly rather than silently miscompiled.

Tier 3 — WASM-side defensive dispatch (medium refactor, optional). Even with Tiers 1+2, an inference miss would still produce a compile error rather than working code. The defensive option: peek at the WASM stack type at the interpolation site (i32_pair → route to string concat) regardless of what Vera-side inference says. Belt-and-braces; defer until/unless we see another trigger after T1+T2 land.

Recommended

Single PR doing Tier 1 + Tier 2 together. Tier 1 alone is producer-side; Tier 2 alone is consumer-side; together they make the next trigger either impossible (Tier 1) or instantly diagnosable (Tier 2). Tier 3 is overkill until we observe further recurrence.

Acceptance criteria

  • All five existing helpers either deleted or thinly delegate to _canonical_vera_type.
  • All 8 triggers above remain pinned by their existing regression tests in tests/test_codegen.py::TestStringInterpolation.
  • The fallthrough at operators.py:482 raises a structured diagnostic instead of emitting to_string(...) over an unknown type; new test pins the diagnostic.
  • No behavioural change for any existing program — pure refactor + diagnostic conversion.

Pairs with #626

#626 covers the broader silent-skip pattern (translator return None → silent degradation). This issue is the specific instance where the silent skip happens in canonicalisation rather than translation. Tier 2 of this issue is essentially #626 Layer 1 applied to a single site; if #626 lands first, Tier 2 here folds into it.

Origin

Pattern emerged across PR #629's review cycle — five separate review passes each surfacing one or two more triggers of the same bug class. CodeRabbit and silent-failure-hunter agents both consistently identified new shapes faster than local fixes could close them. The 8th trigger pushed past the threshold where reactive fixing is cheaper than structural refactor.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions