Rewrote 20 issue writeups to the extern/runtime-class vocabulary (#foreign→extern, foreign_class_map→runtime_class_map, parseForeignClassDecl→parseRuntimeClassDecl, findForeignMethodInChain→findRuntimeMethodInChain, dedupeForeignSymbol→ dedupeExternSymbol, is_foreign_c_api→is_extern_c_api, stale filename refs to the renamed examples, foreign-class→runtime-class, bare foreign→extern). Renamed issues/0043-…-foreign-class-…→…-runtime-class-…. PHASE 9 COMPLETE — 9.4 GATE PASSES: zero 'foreign' across src/library/examples/ issues/docs/editors/specs/readme/CLAUDE, excluding only the SQLite API constant SQLITE_CONSTRAINT_FOREIGNKEY + vendored sqlite3.c/.h (upstream third-party). Suite green (644 corpus / 443 unit, 0 failed).
9.5 KiB
0076 — builtin/reserved type name wrongly accepted as an identifier
Status: RESOLVED.
Root cause: the language accepted a value binding (local/global
varor a parameter) spelled as a reserved/builtin type name. The parser turns such a spelling into a.type_exprrather than an.identifier(parser.zig, viaType.fromName), so the address-of family insrc/ir/lower.zignever saw a scoped local and fell through to value lowering — loading the whole aggregate and passing it by value to aptrparameter (LLVM verifier abort, or a silent*self-mutation-losing copy).Fix: a declaration-site diagnostic in the existing semantic pass
src/ir/semantic_diagnostics.zig(UnknownTypeChecker).checkBindingNamerejects any binding name whose spelling collides with a reserved type name;isReservedTypeNamedefers to the parser's own classifier (types.Type.fromName) so the rejected set never drifts from the set that would parse as a type — the named builtins (bool,string,void,f32,f64,usize,isize,Any) and[su]Nover sx's 1–64 range. Bare value names (s,self,index) are untouched. No lowering special-case is added; the.identifier-only address-of paths are correct once type-shaped names can never be bound. The rejectedbareVarNameapproach was never landed.Coverage is structural (attempt 4). Earlier landings hand-walked a subset of binding-bearing nodes with a silent
else => {}, so each review found a new leaking syntactic form (destructure names,implmethod params/locals,if/while/for/ match-arm /catch/onfailcaptures) that bypassed the check and hit the original LLVM verifier abort.checkBindingNamesis now an exhaustiveswitchover everyNode.Datatag with NOelsearm: a future binding-bearing node type fails to compile until it is handled here, so coverage is enforced by the compiler rather than by a hand-maintained list. The check stays in the pre-lowering semantic pass (NOT moved to theScope.putscope-registration choke point) because lowering is lazy — an UNCALLED function's bindings never reachScope.put, yet they must still be rejected at their declaration (e.g.examples/1119's never-calledtakes_u8).Span precision (attempt 5). Every binding form now carries its own name span in the AST (
VarDecl.name_span,DestructureDecl.name_spans,IfExpr/WhileExpr.binding_span,ForExpr.capture_span/index_span,MatchArm.capture_span,CatchExpr/OnFailStmt.binding_span,Protocol/RuntimeMethodDecl.param_name_spans), populated by the parser at each binding site.checkBindingNamespasses that span to the diagnostic, so the caret underlines the offending identifier itself instead of the enclosing statement /if/match/protocol/#objc_classblock. No call site falls back to the parentnode.span. Regularfn/lambda params already usedParam.name_span.Regression tests:
examples/0125-types-type-named-var-rejected.sx—:=form (i2) rejected.examples/1119-diagnostics-reserved-type-name-as-identifier.sx— parameter (u8), typed-local (i64,bool), and:=(string) forms rejected.examples/1121-diagnostics-reserved-name-control-flow.sx— destructure name,if/whileoptional bindings,forcapture + index names, match-arm capture.examples/1122-diagnostics-reserved-name-impl-method.sx—impl-block method reserved param AND reserved local.examples/1123-diagnostics-reserved-name-catch-onfail.sx—catchandonfailerror-tag bindings.examples/1124-diagnostics-imported-reserved-destructure.sx— destructure name reserved in an IMPORTED module (renders against that module's source).examples/1125-diagnostics-reserved-name-method-param.sx— protocol default-body method param AND sx-defined#objc_classmethod param, each caret landing on the parameter token.examples/0135-types-self-streaming-nonreserved.sx— positive:*selfstreaming with non-reserved names (hasher,ctx) accumulates correctly via bothupdate(@h, …)andh.update(…).Pre-existing example
examples/0904-...declared localsi1/i2(incidental names); renamed tofilled/empty.Coverage extension (issue 0077). The first landing scoped the binding check to main-file decls (matching the unknown-type check's trusted-imports convention); an imported module could still declare
i2 := …and hit the original LLVM verifier abort. The reserved-name binding diagnostic now runs over EVERY compiled module — imported user modules (descending thenamespace_declanmod :: #importwraps) AND the stdliblibrary/— and the twou1locals inlibrary/modules/ui/renderer.sxwere renamed accordingly. The unknown-type check (issue 0064) stays main-file-only. See issue 0077 for the imported-module facet and its pinned regression testexamples/1120-diagnostics-imported-reserved-type-name.sx.
Symptom (how it first surfaced)
A local variable whose name is lexically a type — e.g. i2 (the sN
arbitrary-width signed-int syntax: Type.fromName("i2") → s(2)), or u8,
i64, etc. — is accepted as a variable. Because such a name parses as a
.type_expr (not .identifier), the address-of family of lowering sites
(@i2, the autoref i2.update(...) receiver, a bare f(i2) at a *T param,
global function-pointer args) does NOT recognize it as a scoped local and falls
through to value lowering — loading the whole aggregate and passing it by
value to a ptr parameter:
LLVM verification failed: Call parameter type does not match function signature!
call void @update(ptr @__sx_default_context,
{ [8 x i64], [64 x i8], i64, i64 } %load, ...)
For some struct shapes it compiles but silently passes a copy (callee
*self mutations lost). A non-type-shaped name (hasher, ctx) never triggers
any of this — the .identifier paths already work correctly.
Root cause
The language is accepting reserved/builtin type names as identifiers in the
first place. sN/uN (arbitrary-width ints) and the named builtins
(bool, string, void, f32, f64, i8/i16/i32/i64,
u8/u16/u32/u64, …) are reserved type names; declaring a variable with
such a name is meaningless and produces the mis-lowering above. Patching each
address-of site to tolerate the name (the rejected bareVarName approach) is
whack-a-mole — there is always another site, and it entrenches a name that
should never have been allowed.
Proper fix (the required direction)
Emit a diagnostic error when an identifier is declared with a name that
collides with a builtin/reserved type name — including the arbitrary-width
[su][0-9]+ (sN/uN) family AND the named builtins (bool, string,
void, f32, f64, the fixed-width int types, etc.). Scope ruling (Agra):
all builtin/reserved type names are rejected as identifiers. (User-defined
struct/type-name shadowing, if intentionally supported elsewhere, is out of
scope for this issue — this is specifically about builtin/reserved type names.)
Diagnostic at the declaration site, e.g.:
error: 'u8' is a reserved type name and cannot be used as an identifier
with the declaration's span.
Suspected area: name binding / declaration handling — where a := / typed
local / parameter name is introduced. Reject the name there, before it ever
reaches lowering. Do NOT add lowering special-cases for type-shaped names; the
.identifier-only checks at the address-of sites are then correct as-is (no
type-shaped name can reach them).
Reproduction
#import "modules/std.sx";
Sha256 :: struct { h:[8]u64; block:[64]u8; block_len:i64=0; total_len:u64=0; }
init :: () -> Sha256 { s:Sha256=---; s.block_len=0; s.total_len=0; s }
update :: (self:*Sha256, data:string) { self.total_len += data.len; }
main :: () -> i32 { i2 := init(); update(@i2, "."); print("total_len={}\n", i2.total_len); return 0; }
./zig-out/bin/sx run <file> today → LLVM verifier abort.
Expected after fix: a clean compile-time diagnostic that i2 is a reserved
type name and cannot be an identifier (exit non-zero, readable error — NOT an
LLVM abort, NOT a silent copy). The same program with a non-reserved name
(hasher := init(); update(@hasher, ".")) must compile and print total_len=1.
Verification
- Pinned diagnostics test(s) asserting the error for representative reserved
names used as identifiers:
i2,u8,i64,bool,string(declaration forms::=, typed local, and a parameter name). Capture the diagnostic text inexpected/. - A positive test: the same
*selfstreaming pattern with NON-reserved names (hasher,ctx) compiles and accumulates state correctly via bothupdate(@h, ...)andh.update(...)— proving the.identifierpaths are correct and no lowering special-case is needed. zig build && zig build test && bash tests/run_examples.shall green. If any existing example/test declares a variable with a reserved type name, it is now illegal — fix the test's variable name (do NOT weaken the diagnostic). Report how many such sites existed.
Provenance
Discovered by the distribution flow (P1.2 pure-sx SHA-256), whose minimal repro
happened to name a local i2. Real SHA-256 code with names like hasher/ctx
is unaffected on the current compiler — so the P1.2 "blocker" was a
naming artifact, and this issue is really a missing-diagnostic correctness bug.