Closes the two residual silent holes in the unknown-type diagnostic: - Nested closure / function bodies. The body walk stopped at closure and nested-fn boundaries, so a typo'd type in a closure's local annotation silently became a 0-field struct. `walkBodyTypes` now descends control flow and expressions to re-enter each closure / nested fn via `checkScope`, which accumulates that scope's generic + value-`Type` params onto the parent's — so an inner closure still sees the outer function's `$T` (no false positive) while a genuine unknown is flagged at any nesting depth. `harvestScopeDecls` collects type-decl names across the whole body (including nested scopes) up front so locals are never false-flagged. - Cast targets. `cast(T)` where `T` is a value-`Type` param (no `$`) cast to a fabricated empty struct silently; it now gets the tailored `$T` hint. An unknown *literal* cast target already errors via value resolution, so it's left to that path — no double diagnostic. Suite: 350 passed, 0 failed. Regressions: examples/1114 (nested-closure annotation), 1115 (cast value param).
5.2 KiB
0064 — non-$ T: Type function param used as a type silently yields {}
✅ RESOLVED (2026-06-02). Root cause as diagnosed: an identifier in a type position that resolved to nothing fell through to
type_bridge.resolveTypeName's empty-struct stub, silently interning a 0-field struct under the name. Fix (option 2, surfaced as a diagnostic): a new post-scan passcheckUnknownTypeNames([src/ir/lower.zig], Pass 1f) walks every main-file function signature and non-generic struct field type and rejects any leaf name that is not a primitive, an in-scope generic param ($T/type_params), a declared type, or a real (non-stub) registered type. The load-bearing empty-struct stub is left intact (forward references + foreign-class opaque types still rely on it during the scan); the pass runs after scanning and before body lowering, socore.zig'shasErrors()halts the build before any stub reaches codegen. A value param used as a type gets the tailored hint "'T' is a value parameter, not a type; introduce a generic type parameter with$T: Type"; a genuine unknown name gets "unknown type 'X'". Imported concrete types are recognized via the type table (findByName), so cross-module references aren't false-flagged; inline compound spellings ([:0]u8), arbitrary- width ints (u1/u2), and$-introduced generics (-> $R) are all exempted. The pass also walks function bodies (checkBodyTypes+collectBodyDeclNames): localvar/consttype annotations — including insideif/ loop /match/push/defer/onfailblocks and decl-value blocks — are checked with the enclosing function's generic params in scope, and body-localT :: struct/enum/ uniondeclarations are collected so they aren't false-flagged. This closes the silent body-level hole wherev: Coordnate = 5(a non-existent type) compiled and ran with the value dropped. Nested function / closure bodies are their own scope and are not descended (safe under-coverage); explicitcast(T)already has its ownunresolveddiagnostic and is left to it. The walk descends into nested closure / function bodies too (walkBodyTypes
checkScope): each scope accumulates its generic params onto the parent's, so a closure body still sees the enclosing function's$T, and a type annotation in any nesting depth is checked.harvestScopeDeclscollects type-decl names across the whole body (including nested scopes) so locals aren't false-flagged. Cast targets are handled too:cast(T)whereTis a value-Typeparam (the otherwise-silent cast case) gets the tailored hint, while an unknown literal cast target is left to the existing value-resolutionunresolveddiagnostic (no double-report). The only remaining under-coverage is benign (annotations buried in AST positions the walker doesn't descend stay unchecked — never a false positive). Regression tests:examples/1111(tailored hint, signature),1112(typo'd field type),1113(body-level local annotation),1114(nested-closure annotation),1115(castvalue param) — all exit 1. Suite: 350 passed, 0 failed.
Symptom
A function parameter declared T: Type (or lowercase T: type) — i.e. WITHOUT
the $ generic-type-parameter sigil — that is then referenced in a type position
(-> T, Closure() -> T, etc.) silently resolves T to a fabricated empty
struct {} instead of the caller's argument type. The function "runs" but
produces garbage (the value renders as T{}), with no diagnostic.
idwrap :: (T: Type, f: Closure() -> T) -> T { return f(); }
main :: () -> s32 {
print("{}\n", idwrap(s32, closure(() -> s32 { return 7; }))); // prints "T{}", want 7
return 0;
}
The correct, working form is the generic $T: Type (the $ introduces the
generic type parameter — see specs.md §"$ generic type parameter
introduction"). With $T, the binding is established and the result is 7.
So this is not a miscompile of a valid program — it's a missing diagnostic
for a misuse: a non-generic Type-typed value param can't be used as a type, and
should be rejected (or the $ requirement explained), not silently turned into
an empty struct.
Reproduction
See above. Compare idwrap :: ($T: Type, …) (works, prints 7) vs idwrap :: (T: Type, …) (prints T{}).
Investigation prompt
In src/ir/lower.zig, resolveTypeWithBindings resolves a
.type_expr named T by checking type_bindings (works for $T, which
buildTypeBindings registers). For a non-$ T: Type param there is no binding,
so resolution falls through to type_bridge.resolveAstType, which fabricates an
empty-struct stub for the unknown name T — the classic "silent empty struct"
the CLAUDE.md REJECTED-PATTERNS warn about. Fix options: (1) at scan/sema time,
reject referencing a non-$ Type-typed param in a type position with a
diagnostic ("type parameter must be introduced with $ — write $T: Type"); or
(2) make resolveAstType return .unresolved + a diagnostic for an unknown bare
type name in a generic-eligible position, instead of stubbing {}. Deferred —
orthogonal to ERR; the working $T idiom exists. Low priority but should not stay
silent.